Skip to content

Commit e3fed3e

Browse files
committed
Merge origin/main: resolve the #9633 couplings now that #9635 and #9646 landed
Merged rather than rebased: rebasing a pushed branch needs a force-push, which the dev contract forbids outright. Same outcome, no forced history. Both blockers are now on main (#9635 at c07d6e8, #9646 at 73010f1), so the three points this card was holding are resolved here: 1. Field JSDoc — the clause naming replay as an exception is gone. Not a mechanical delete: it left "Two things are unaffected either way" counting wrong, so the sentence is rewritten to say `false` writes no rows at all, replay included, with the sys_job counters as the one exception. 2. Class JSDoc — the "one row it does not govern" sentence is gone. The bullet keeps its single {@link DbJobAdapterOptions.recordRuns} pointer rather than paraphrasing the flag a second time, per the constraint #9631 was built on. 3. #9646's fifth test case, which pinned "replay writes anyway", is deleted. It carried a comment saying it would change together with the class JSDoc if this card ruled the carve-out shut. It did. A note stands where it was. Test-file conflict was add/add — both describe blocks appended at the end. Both are kept; they cover different paths (#9631's the wrap() path, this card's the replay() path). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
2 parents 3d8614a + 73010f1 commit e3fed3e

29 files changed

Lines changed: 765 additions & 73 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
Published README points at the `services.audit` reference again, in the form a published reader can follow (#9589)
6+
7+
PR #9531 dropped this README's "See Also" pointer to the runtime-services audit
8+
page because the page was measured wrong — it documented `record()` /
9+
`'set' | 'reset'` (the settings sink) as if that were the `audit` slot. PR #9587
10+
rewrote the page around the real slot, so the reason for the omission has stopped
11+
holding, and the link is restored.
12+
13+
It is restored because the page carries three things this README deliberately
14+
does not, each verified against the page as it stands on `main` rather than
15+
against the PR title that rewrote it:
16+
17+
- **the failure posture of the slot itself** — `recordAuthEvent` never throws; a
18+
failed ledger insert is reported at `error` level once per process and then
19+
drops to `debug`, the row is lost and nothing retries it, and the call silently
20+
no-ops when no data engine resolves or when `userId` is absent. This README
21+
documents the *record-view batcher's* two failure postures, which are a
22+
different code path; it says nothing about this one.
23+
- **the event's field-by-field shape** — that `userId` must be a real `sys_user`
24+
id, that `sessionId` lands on `record_id` with `object_name` fixed to
25+
`sys_session`, that `organizationId` stamps the tenant columns and an unstamped
26+
row is one non-administrator members can never see, and that `context` is
27+
serialized into `metadata`. This README states the slot's interface and its
28+
closed `'login' | 'logout'` action union, and deliberately stops there.
29+
- **the settings-sink disambiguation** — that `SettingsAuditSink.record()` is
30+
never registered as or resolved from this slot, and that
31+
`getService('audit').record({ ... })` therefore fails with a `TypeError`.
32+
33+
The restored line is **not** the line #9531 removed. That one read
34+
`[Audit Logging Best Practices](/content/docs/kernel/runtime-services/audit-service.mdx)`
35+
— a label describing a best-practices guide the page has never been, and a
36+
repo-path-rooted URL that resolves for neither of this README's published
37+
audiences. A README in the package's `files` array is rendered on npm and on
38+
GitHub, where a root-relative href resolves against `npmjs.com` / `github.com`,
39+
not against the docs site. The replacement uses the absolute
40+
`https://docs.objectstack.ai/docs/...` form that `create-objectstack`'s published
41+
READMEs already use, and its annotation states what the page adds — so the next
42+
author weighing the same omission can check the justification instead of
43+
reconstructing it.
44+
45+
The one pre-existing site-root-relative docs link in this same file
46+
(`/docs/permissions/permission-sets#access-depth...`, added by the same PR) is
47+
converted to the same absolute form. Its target page and heading anchor both
48+
exist; only the spelling was unfollowable off the docs site.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Register `FLOW_CONVERSION_CONFLICT` (409) in the ADR-0112 error-code ledger under
6+
`@objectstack/metadata-protocol` (#9567). The code was already live on the wire —
7+
`saveMetaItem`'s flow-conversion rename guard (`protocol.ts`) has thrown it since
8+
ADR-0078 landed, already SCREAMING_SNAKE — but was invisible to
9+
`check:dispatcher-error-vocabulary`'s scan because the site stamps it through a
10+
cast (`(err as any).code = 'FLOW_CONVERSION_CONFLICT'`) rather than the bare-
11+
identifier `assign` shape the scan matched at the time. This is an ordinary,
12+
additive admission: no accept/reject behavior, no producer, and no wire shape
13+
changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-job": patch
3+
---
4+
5+
fix(services): the `DbJobAdapter` class JSDoc stops promising a `sys_job_run` row that `recordRuns: false` never writes (#9631)
6+
7+
`tsup` emits this comment into `packages/services/service-job/dist/index.d.ts`, so it is
8+
the class-level editor tooltip an npm consumer of `@objectstack/service-job` reads. Its
9+
third "persisted side effects" bullet said **every execution writes a `sys_job_run` row**;
10+
`wrap()` gates that insert on `recordRuns`, which defaults to `true` but writes nothing at
11+
all when set to `false`. The same emitted `index.d.ts` states the truthful field-level rule
12+
for `recordRuns` sixty lines above, so the published declaration disagreed with itself
13+
about one flag — a reader hovering either one got a different answer.
14+
15+
No runtime behaviour changes. This is a patch because the entire deliverable is text inside
16+
a published package's `.d.ts`: with no version bump the corrected tooltip never reaches npm
17+
and the fix is unmet in the only channel it is about.
18+
19+
The corrected bullet defers to `DbJobAdapterOptions.recordRuns` via `{@link}` rather than
20+
restating the rule, so the two cannot drift apart again, and it names the one row the flag
21+
does not govern — `replay()`'s synthetic `trigger: 'replay'` row, written either way. The
22+
fourth bullet gains the matching negative: the `sys_job` counters are bumped
23+
unconditionally, `recordRuns` gating only the per-attempt rows.
24+
25+
Five cases now pin the flag in both directions. Nothing in this package referenced
26+
`recordRuns` before, so both corrected sentences were accurate but unenforced.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/service-cache": patch
3+
"@objectstack/service-job": patch
4+
---
5+
6+
fix(services): two published `.d.ts` JSDoc comments stop describing behaviour their code does not have — `MemoryCacheAdapter` eviction is FIFO, not LRU, and `recordRuns` is an on/off switch, not a retention cap (#9611)
7+
8+
Both comments are emitted by `tsup` into each package's built `index.d.ts`, so they
9+
are **the editor tooltip an npm consumer sees** — the same "published documentation
10+
asserting behaviour the runtime does not have" class as #9517 and #9532, in a third
11+
channel that no gate reads. No runtime behaviour changes in either package; this is a
12+
patch because the corrected text only reaches consumers through a release.
13+
14+
**1. `MemoryCacheAdapter` — "LRU-style eviction" was never LRU.**
15+
16+
The class comment advertised `TTL-based expiry and LRU-style eviction`. The eviction
17+
path takes `this.store.keys().next().value` — the first key in `Map` insertion order —
18+
and `get()` returns `entry.value` without ever deleting and re-setting the key, so a
19+
read does not move an entry back. Nor does an overwrite: `Map.set` on a key already
20+
present keeps its original insertion slot. Eviction has therefore always been
21+
**oldest-inserted (FIFO)**, which is a materially different hit-rate profile from the
22+
one the tooltip promised anyone sizing a cache.
23+
24+
The comment was corrected rather than the code, deliberately: `maxSize` defaults to `0`
25+
(unlimited), so the eviction path is off by default and nothing shipped is getting FIFO
26+
where it expected LRU, and there is no measured pull for LRU. Minting a real behaviour
27+
change to make a stale sentence true inverts the fix — the defect is that the
28+
documentation lies, not that the cache is wrong. (A real LRU already exists in the repo,
29+
`packages/metadata/src/utils/lru-cache.ts`, for the callers that need one.)
30+
31+
Four tests now pin the corrected sentence so it stops being an unenforced claim. Each is
32+
written as a **discriminator against LRU**: it reads (or overwrites) the oldest entry
33+
before overflowing the cache and then asserts that entry was evicted anyway — a hot key
34+
dies on age, an untouched newer key survives. The pre-existing eviction tests could not
35+
tell the two policies apart, which is how the wrong comment sat green.
36+
37+
**2. `DbJobAdapterOptions.recordRuns` — the comment described a different field.**
38+
39+
`/** Soft cap on sys_job_run rows recorded per job (defaults to none — handled by
40+
retention jobs) */` made three claims and the code contradicts all three: the field is a
41+
`boolean`, not a count; it defaults to `true`, not "none" (`args.options?.recordRuns ??
42+
true`); and it gates whether a `sys_job_run` row is written at all, rather than being
43+
trimmed later by retention. The sentence reads as if it belongs to the numeric
44+
`JobRunRetention` knob that ADR-0057 retired — a copy-paste that outlived its source.
45+
46+
The consequence the new wording keeps in sight: **a reader who sets `recordRuns: false`
47+
expecting "no cap" gets run history switched off.** The replacement states the real
48+
meaning (one row per attempt, inserted at start and updated on settle, default `true`)
49+
and both things that are *not* affected by the flag — the `sys_job` row's own
50+
`last_status` / `run_count` / `failure_count` counters, which `bumpJob` updates
51+
regardless, and `replay()`, which writes its synthetic `trigger: 'replay'` row without
52+
consulting the flag at all.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Published agent-authoring prompts now reference real exports.
6+
7+
`prompts/create-new-project.md`, `prompts/implement-objectql.md` and
8+
`prompts/implement-objectos.md` told agents to import five symbols that
9+
`@objectstack/spec` does not export. Four failed loudly. The fifth did not:
10+
`import { Object } from '@objectstack/spec/data'` does not resolve, so the
11+
annotation in `export const AccountObject: Object = { ... }` bound to the
12+
**JavaScript global** `Object` instead — metadata authored from that prompt
13+
type-checked against a type that constrains nothing.
14+
15+
- Object definitions now use the house authoring convention measured in the
16+
example apps, `ObjectSchema.create({ ... })`, which genuinely validates.
17+
Correcting it exposed that the prompt's own example set `enable.audit` /
18+
`enable.workflow`, neither of which exists; they are now `trackHistory` /
19+
`files`, the pair the schema's own docstring uses.
20+
- `implement-objectql.md` keeps the real `Field` and `QuerySchema` imports and
21+
derives the object metadata type as `z.infer<typeof ObjectSchema>`, matching
22+
both `prompts/instructions.md` ("interfaces must be inferred from Zod") and
23+
spec's own `src/contracts/schema-driver.ts`.
24+
- `ManifestSchema` becomes `ObjectStackDefinitionSchema` from the package root:
25+
the prompt's subject is `objectstack.config.ts`, which is neither of the
26+
`/system` manifests.
27+
- `IdentitySchema` / `PolicySchema` have no bare referent; Rule #2 now names
28+
`RLSUserContextSchema` and `RowLevelSecurityPolicySchema` from
29+
`@objectstack/spec/security`.
30+
- The three non-existent "Key Files to Watch" paths
31+
(`system/{manifest,identity,events}.zod.ts`) now point at `stack.zod.ts`,
32+
`security/rls.zod.ts` and `kernel/events.zod.ts`.

‎.github/workflows/lint.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,21 @@ jobs:
486486
- name: Quick-reference section counts match their tables
487487
run: pnpm check:quick-reference-counts
488488

489+
# #9604: the runtime-services chapter's pages are enumerated in three
490+
# hand-written places (meta.json "pages", the chapter list in
491+
# runtime-services/index.mdx, the services.* table in kernel/index.mdx) and
492+
# nothing read any of them -- check:docs-audit-scope derives WHICH pages the
493+
# accuracy audit covers, never whether an index enumerates them. #9604 found
494+
# services.sms with a page, a meta.json entry, a registered slot and a
495+
# canonical-source row, missing from BOTH index lists; #9588 was the same
496+
# page drifting on a different line. The pages on disk are the source of
497+
# truth and the gate holds all three enumerations (and the chapter list's
498+
# order) to them. It lives in this job with the other docs guards: the edit
499+
# that breaks it is a docs edit, so a packages/** paths filter would blind
500+
# it to its own failure mode.
501+
- name: Runtime-services indexes enumerate the chapter's real pages
502+
run: pnpm check:runtime-services-index
503+
489504
# #3723 ADR anchors: code an accepted ADR governs must keep naming it.
490505
# That incident reversed three accepted ADRs with a patch-level changeset,
491506
# and the mechanism was simply that the edited file never mentioned them —

‎content/docs/api/data-api.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,14 @@ Delete a record.
222222

223223
**Response**: `{ object: "account", id: "1", success: true }`
224224

225+
Every relation pointing at the deleted record honours its own `deleteBehavior`
226+
(`cascade` / `set_null` / `restrict`). On a `multiple: true` reference, `set_null`
227+
removes just the deleted id from the array and keeps the rest, and a reference set
228+
emptied that way reads back as `[]` — never `null`, so a client that branches on
229+
`null` for "no link" misses the emptied case (the `multiple` doc block in
230+
`packages/spec/src/data/field.zod.ts`, rendered in the
231+
[Field reference](/docs/references/data/field)).
232+
225233
---
226234

227235
## Batch Operations

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ Reference to a record in another object (foreign key).
315315
|:---|:---|:---|:---|
316316
| `reference` | `string` | **required** | Target object name (snake_case) |
317317
| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property (unknown keys are stripped by the schema). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
318-
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted (a *required* lookup left at the default `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared) |
318+
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted (a *required* lookup left at the default `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared). On a `multiple: true` lookup `set_null` removes only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` |
319319

320320
```typescript
321321
{ name: 'company', label: 'Company', type: 'lookup', reference: 'account' }
@@ -655,7 +655,7 @@ These properties are available on **all** field types:
655655
| `type` | `FieldType` | **required** | One of the supported field types |
656656
| `required` | `boolean` | `false` | Whether the field is required |
657657
| `unique` | `boolean` | `false` | Enforce uniqueness |
658-
| `multiple` | `boolean` | `false` | Allow array of values |
658+
| `multiple` | `boolean` | `false` | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied multi-value lookup reads back as `[]`, never `null` — the `multiple` doc block in [`FieldSchema`](/docs/references/data/field) |
659659
| `searchable` | `boolean` | `false` | Include in search index |
660660
| `sortable` | `boolean` | `true` | Allow sorting by this field |
661661
| `hidden` | `boolean` | `false` | Hide from default views |

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,13 @@ rejected with `400 VALIDATION_FAILED` and a `fields[]` entry whose `code` is
189189
`reference_not_found` (see the [error catalog](/docs/api/error-catalog)). The
190190
same check runs on bulk updates.
191191

192-
Clearing a relationship is not a dangling reference: `null`, `""` and `[]` mean
193-
"no link" — exactly what `deleteBehavior: 'set_null'` writes when the parent
194-
goes away.
192+
Clearing a relationship is not a dangling reference: `null`, `""` and `[]` all mean
193+
"no link". Which of them `deleteBehavior: 'set_null'` writes when the referenced
194+
record goes away depends on the field: a single-value lookup is set to `null`, while
195+
a `multiple: true` lookup keeps its other members and loses only the deleted one —
196+
and a set emptied that way is written as `[]`, never `null` (the `multiple` doc block
197+
in `packages/spec/src/data/field.zod.ts`, rendered in the
198+
[Field reference](/docs/references/data/field)).
195199

196200
<Callout type="info">
197201
`deleteBehavior` governs what happens to *this* record when the **referenced**

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,26 @@ These properties apply to **all** field types and are validated by the base `Fie
244244
| `reference` | `string` | — | **Required.** Target object name |
245245
| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer part of the `Field` schema; use `lookupFilters` + `dependsOn` instead |
246246
| `deleteBehavior` | `enum` | `set_null` | `set_null`, `cascade`, or `restrict` |
247-
| `multiple` | `boolean` | `false` | Allow multiple references |
247+
| `multiple` | `boolean` | `false` | Allow multiple references; the value is validated as an array of ids |
248248

249249
**Default constraints:** Validates that referenced record exists. Foreign key integrity enforced.
250250

251+
**Multi-value lookups (`multiple: true`).** The empty set is representable: an emptied
252+
multi-value lookup is stored and read back as `[]`, never `null` — including when
253+
`deleteBehavior: 'set_null'` removes the last remaining member. The declared contract
254+
lives in the `multiple` and `required` doc blocks of
255+
`packages/spec/src/data/field.zod.ts` (rendered in the
256+
[Field reference](/docs/references/data/field)), and it makes `required` on a
257+
multi-value lookup mean **non-empty array**.
258+
259+
<Callout type="warn">
260+
The `required`-means-non-empty half is **declared but not yet enforced** by the record
261+
validator: today `[]` passes the required check on both insert and update, because the
262+
check treats only `undefined`, `null` and blank strings as missing. Validate emptiness
263+
in application code until that lands (tracked in #9476). The representation guarantee
264+
above (`[]`, never `null`) is live.
265+
</Callout>
266+
251267
### `master_detail`
252268

253269
| Property | Type | Default | Validation Behavior |

0 commit comments

Comments
 (0)