Skip to content

Commit 711cde7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/pagination-tie-breaker-missing-p98qf0
# Conflicts: # .changeset/paged-read-determinism.md # packages/plugins/driver-memory/src/memory-pagination-conformance.test.ts # packages/plugins/driver-mongodb/src/mongodb-driver.ts # packages/plugins/driver-mongodb/src/mongodb-pagination-conformance.test.ts # packages/plugins/driver-sql/src/sql-driver-pagination-conformance.test.ts # packages/plugins/driver-sql/src/sql-driver.ts # packages/spec/api-surface.json # packages/spec/src/contracts/data-driver.ts # packages/spec/src/data/index.ts # packages/spec/src/data/pagination-conformance.ts
2 parents 2bdf7d6 + d92c72d commit 711cde7

78 files changed

Lines changed: 2338 additions & 212 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): the sandbox reports an action body's discarded `ctx.record` writes at invocation time (#4345)
6+
7+
#4362 closed the author-time half of #4345: `action-record-write-discarded`
8+
warns when a body assigns to `ctx.record` and the snapshot is provably dead.
9+
This is the run-time half, and it exists because a parse cannot reach three
10+
things a running action can:
11+
12+
- **computed keys and aliases**`ctx.record[k] = v`, `const r = ctx.record;
13+
r.x = 1`, which the lint deliberately skips rather than guess at;
14+
- **a wholesale replacement**`ctx.record = {…}`;
15+
- **bodies no lint ever sees** — metadata authored through Studio or the API
16+
never passes through `os validate` / `os lint` / `os compile`.
17+
18+
The sandbox installs a `set`/`deleteProperty`/`defineProperty` proxy over the
19+
snapshot, behind an accessor so a wholesale replacement cannot swap the recorder
20+
out, and surfaces the touched keys as `ScriptResult.droppedRecordWrites`.
21+
`actionBodyRunnerFactory` logs a warning naming the discarded fields and the
22+
`ctx.api.object(...).update(...)` remedy. Writes still work *inside* the VM, so
23+
a body using the snapshot as scratch keeps its reads coherent — only the silence
24+
is removed.
25+
26+
**Only dead writes are reported**, on the same reading #4362 uses: a snapshot
27+
that leaves the body as a value may have carried the write with it, so
28+
29+
```js
30+
ctx.record.stage = 'won';
31+
await ctx.api.object('crm_deal').update(ctx.record); // lands — stays quiet
32+
```
33+
34+
is not reported, while a plain property read does not rescue a write (the
35+
`ctx.recordId || (ctx.record && ctx.record.id)` guard idiom real action bodies
36+
are written with still reports). An `ownKeys` after a write marks the escape.
37+
A wrong "discarded" asserts something false about the stored record, which is
38+
worse than a miss.
39+
40+
Hooks carry no `record`, so they install no proxy and pay nothing. `ctx.record`
41+
remains read-only; whether the runtime should instead refuse or honour the write
42+
is still open — reporting a discard prejudges neither answer.

.changeset/drop-require-auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ switch:
3232
`RestApiConfigSchema` and the stack `api` block, so authoring it now fails with
3333
a fix-it message rather than being silently stripped (the ADR-0104 / #3733
3434
quiet-failure this whole line of work has been closing). `os migrate meta`
35-
drops it via the protocol-18 conversion `stack-api-require-auth-removed`.
35+
drops it via the protocol-17 conversion `stack-api-require-auth-removed`.
3636
- `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth`
3737
input; it denies any anonymous, non-system caller outside the control-plane
3838
allowlist.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
"@objectstack/runtime": patch
4+
"@objectstack/plugin-approvals": patch
5+
"@objectstack/plugin-auth": patch
6+
"@objectstack/plugin-reports": patch
7+
"@objectstack/plugin-webhooks": patch
8+
"@objectstack/spec": patch
9+
---
10+
11+
fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346)
12+
13+
Two changes with different weights, from one sweep of every in-repo engine
14+
call site that still speaks a deprecated alias.
15+
16+
**The bug — three dropped sorts.** #4346 made the engine fold `filter``where`
17+
and `top``limit` on all six methods. The other four pairs in
18+
`RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at
19+
the RPC/wire layer only — their values need shape lowering that belongs to
20+
those layers — and a **direct `engine.find()` never crosses that layer**. Three
21+
call sites passed `sort` there, so it rode onto the AST untouched, every
22+
driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and
23+
the query returned an ordinary-looking, arbitrarily-ordered result:
24+
25+
| call site | asked for | actually got |
26+
|---|---|---|
27+
| `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order |
28+
| `runtime/domains/share-links.ts` | same route, runtime-domain copy | same |
29+
| `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 |
30+
31+
All three combine the dropped sort with a `limit` — the "latest N" shape whose
32+
failure #4226 spelled out: an unapplied sort returns rows in arbitrary order,
33+
which `limit` then slices into an arbitrary page. #4226 fixed that in the wire
34+
normalizer; these calls sit one layer below it. `listLinks` had no test at all,
35+
which is why it went unnoticed. Now pinned — on the option bag the engine
36+
receives, not on row order, because the failure is that the key never becomes
37+
`orderBy` and a fake engine honouring either spelling would pass either way.
38+
39+
**The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing
40+
`filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11,
41+
webhooks 2, plus the one `filters` in a spec doc example). These are strict
42+
no-ops since #4346 folds the alias — the point is that the framework stops
43+
depending on a spelling it asks users to migrate off, which is a prerequisite
44+
for ever retiring the aliases. Service-level `filter` PARAMETERS (each
45+
service's own public API, e.g. `listRequests(filter)`) are deliberately
46+
untouched — those are not engine option bags.
47+
48+
Two of the renamed calls were live victims of the #4346 bug rather than
49+
cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via
50+
`findOne({filter})` and counted the whole table via `count({filter})`, so a
51+
federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already
52+
corrected the behaviour; this makes the call say what it means.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
feat(lint): `flow-node-write-unknown-field` covers `create_record` too (#4271)
6+
7+
#4369 shipped the flow write-set gate on `update_record` alone and parked
8+
`create_record` in `FLOW_WRITE_NODE_TYPES_DEFERRED` with its reason — a gating
9+
rule earning its severity one measured surface at a time, recorded as data
10+
rather than left as silence. This measures the other half and moves it across.
11+
12+
**The INSERT path fails the same way, one notch harder.** Same literal
13+
`config.fields` map, same `objectName` binding, same journey to the driver — the
14+
engine hands an undeclared key to `driver.create` verbatim, alongside the audit
15+
stamps. On SQLite/knex it becomes `table deal has no column named stagee` and
16+
the statement is rejected whole, so the correctly named fields in the same
17+
payload never land either. The extra harm is what does *not* exist afterwards:
18+
the row is never created, so every later node reading `{<node>.id}` from that
19+
node's `outputVariable` is working from a record that was never written. An
20+
`update_record` failure at least leaves the record intact.
21+
22+
So the message now names that consequence on `create_record` and only there —
23+
"…and the record is never created at all" — instead of one sentence blurred to
24+
fit both.
25+
26+
Nothing else moves: same rule id, same `error` severity, the same silent bails
27+
(templated `objectName`, non-literal `fields`, cross-package objects, objects
28+
declaring no fields, dotted keys), and `runAs` is still not consulted. Each skip
29+
is now pinned on the create surface as well as the update one, so the two node
30+
types cannot drift into different behaviour.
31+
32+
**`FLOW_WRITE_NODE_TYPES_DEFERRED` is now empty and deliberately kept.** The
33+
partition test derives the full `fields`-write-map set behaviourally from the
34+
spec's executor-written config schemas, so a node type that grows one later
35+
belongs to neither list and fails that test until someone classifies it.
36+
Deleting the empty array would turn that forced decision back into a default.
37+
38+
Two non-members are now excluded on the shape of their failure rather than by
39+
omission, both stated in the module header and one pinned by a test:
40+
`get_record.fields` is a projection (`z.array(z.string())`) — a READ, where an
41+
unknown entry narrows the selection instead of breaking the statement — and
42+
`screen.defaults` is forwarded into the `ScreenSpec` the client renders, so an
43+
unknown key is a prefill the renderer ignores. That inert "skips it and renders
44+
the rest" case is exactly what this rule's `error` severity is defined against.
45+
46+
Verified against the repo's own apps: app-crm, app-todo and app-showcase all
47+
still validate clean with `create_record` covered — including crm's
48+
convert-lead flow, which creates an account and an opportunity before updating
49+
the lead.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(lint): a flow `update_record` node writing an undeclared field gates the build (#4271)
7+
8+
The write-set family #4305 (hooks) and #4344 (actions) opened had a third
9+
surface, and it was the one the docs had spent the longest recommending as the
10+
safe alternative to the other two. A flow `update_record` node whose
11+
`config.fields` names a field the target object never declares was caught by
12+
**nothing**: `validate-readonly-flow-writes.ts` walks that exact map and
13+
explicitly stepped over the unknown key (`if (!meta) continue; // a
14+
form/field-layout lint concern` — a referral to a rule that does not check
15+
writes), and `validate-flow-template-paths.ts` checks the `{record.<path>}`
16+
READ tokens interpolated into node config, never the write-side key. So the
17+
surface `hook-bodies.mdx` pointed authors at — "prefer a flow `update_record`
18+
node, whose structural `fields` config is checked" — was the least checked of
19+
the three.
20+
21+
**New rule — `flow-node-write-unknown-field`, and it is an `error`.** Wired into
22+
`REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` report
23+
it at once (one more place than the hand-wired readonly rule next door reaches).
24+
25+
**Why it gates where its two siblings advise.** The hook and action rules are
26+
advisory because they PARSE JavaScript: the finding is only as good as the
27+
extractor, and a false positive kills an advisory lint. Nothing here is parsed —
28+
`config.fields` is a literal map next to a literal `objectName`, the same
29+
certainty `flow-update-readonly-field` already gates on one config key over. A
30+
rule that errors on a write the engine *strips* while only warning on a write
31+
that names no column at all would be incoherent in the same `fields` map.
32+
33+
And the runtime consequence is not the benign "consumer skips the unknown name
34+
and renders the rest" that keeps `page-field-unknown` / `form-field-unknown`
35+
advisory. Both halves were measured, not inferred:
36+
37+
- Through the engine, an undeclared key reaches `driver.update` verbatim — the
38+
flow executor calls the data engine directly, the UPDATE path strips only
39+
readonly/readonlyWhen, and the SQL driver's `formatInput` /
40+
`applyWriteColumnMap` pass an unrecognized key straight through (`m[k] ?? k`).
41+
- On SQLite/knex it becomes `update "deal" set "name" = 'n2', "stagee" = 'won' …
42+
→ no such column: stagee`. The statement is rejected **whole**: `name` —
43+
spelled correctly, in the same payload — does not land either, and the step
44+
fails with a driver error naming a column, far from the authoring mistake.
45+
- On a schemaless datasource nothing rejects it, so the stray key is persisted
46+
into a column the object never declares, where no schema-driven read returns
47+
it.
48+
49+
That is the call `validate-searchable-fields` makes for a stale entry and
50+
`validate-flow-template-paths` makes for a filter-position token: gate when the
51+
miss breaks or corrupts the operation, advise when it merely narrows the output.
52+
53+
**One field index and one implicit-field set across all three surfaces.**
54+
`indexObjectFields` and `IMPLICIT_FIELDS` are imported from the hook rule rather
55+
than copied, so the three rules cannot drift on what is writable without being
56+
authored — the shape #4330 collapsed one package over.
57+
58+
Every skip exists so the gate only ever fires on a certainty, and each is
59+
silent: a templated `objectName`, a non-literal `fields` map, an object this
60+
stack does not define, an object that declares no fields at all (external /
61+
datasource-introspected schemas, the same skip `validate-searchable-fields`
62+
takes), and dotted keys (a nested-path write, not a top-level column). `runAs`
63+
is deliberately NOT consulted, unlike the readonly rule that skips
64+
`runAs:'system'` — an elevated identity bypasses the readonly strip, but no run
65+
identity conjures a column.
66+
67+
**Scope is declared as data, not left as silence.** `FLOW_WRITE_NODE_TYPES`
68+
(today `update_record`) and `FLOW_WRITE_NODE_TYPES_DEFERRED` (`create_record`,
69+
with its reason) are partition-tested against the CRUD node types that carry a
70+
`fields` write map — derived behaviourally from the spec's executor-written
71+
config schemas, not restated — so a node type that grows one later fails that
72+
test until someone classifies it.
73+
74+
`@objectstack/spec`: `ScriptBodySchema`'s "prefer a flow `update_record` node,
75+
whose structural `fields` config is error-checked" note now names the rule that
76+
makes it true. Doc comment only — no schema or generated-artifact change.
77+
78+
Docs: #4355 had just rewritten `automation/hook-bodies.mdx` to record this gap
79+
honestly — "**Prefer a flow `update_record` node when the write set is fixed —
80+
but not for *this* check** … writing a field the object never declares is
81+
currently reported by nothing at all. On that one axis an L2 body is now the
82+
better-checked surface." That bullet, and the matching note in
83+
`automation/hooks.mdx`, are the two sentences this change makes false. Both now
84+
say the axis has flipped back — and why the flow side lands a level *stronger*
85+
than the body side rather than merely level with it.

.changeset/query-ast-inert-request-surface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ non-strict `BaseQuerySchema`, so authoring either fails `tsc` (input type
4141
`never`) and a query still carrying one — even as an empty array — fails to
4242
parse with the prescription itself. `QueryAST` is a request shape, never stored
4343
in stack metadata, so there is no `os migrate meta` step: the removals are
44-
registered as protocol-18 **semantic** migrations (`query-joins-retired`,
44+
registered as protocol-17 **semantic** migrations (`query-joins-retired`,
4545
`query-window-functions-retired`), the #4196 precedent.
4646

4747
Compat note for the REST boundary: both names remain **reserved** list-query

.changeset/query-cursor-removed.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Mechanics: `retiredKey()` tombstones on both declaration sites
2525
(`QuerySchema.cursor` and `EngineQueryOptionsSchema.cursor`, one shared
2626
prescription), so authoring the key fails `tsc` and a query still carrying it
2727
fails to parse with the fix. `QueryBuilder.cursor()` is deleted. Registered as
28-
the protocol-18 semantic migration `query-cursor-retired` (request surface —
28+
the protocol-17 semantic migration `query-cursor-retired` (request surface —
2929
nothing stored to rewrite). The caller-built `Record<string, unknown>` shape
3030
would not survive a real keyset design anyway: a first-class cursor, if ever
3131
built, will be a response-minted opaque token (the pattern the

.changeset/query-distinct-removed.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The one-line fix: **delete the key**; deduplicate with `groupBy` /
2929
Mechanics: `retiredKey()` tombstones on both declaration sites
3030
(`QuerySchema.distinct` and `EngineQueryOptionsSchema.distinct`, one shared
3131
prescription); `QueryBuilder.distinct()` is deleted; registered as the
32-
protocol-18 semantic migration `query-distinct-retired`. **Observable REST
32+
protocol-17 semantic migration `query-distinct-retired`. **Observable REST
3333
change (`@objectstack/metadata-protocol`):** the count-suppression branch is
3434
deleted — a list request that used to carry `distinct` now gets a real
3535
`total`/`hasMore` again (that restoration is the point, not a side effect).

.changeset/query-field-node-object-form-removed.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ depth 3).
5050
There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request
5151
shape, never stored in stack metadata, so the chain has no source to rewrite. It
5252
is registered as an ADR-0087 D3 **semantic** migration
53-
(`query-field-node-object-form-retired`) on the protocol-18 step instead — the
53+
(`query-field-node-object-form-retired`) on the protocol-17 step instead — the
5454
`EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers
5555
move their own select lists, and both channels tell them how:
5656

.changeset/retire-batch-validate-only.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ deliberately when there is a real need.
2424
rather than being silently stripped (the ADR-0104 / #3733 quiet-failure class).
2525
The `BatchOptions` type's `validateOnly` becomes `never`.
2626
- The retirement is HTTP-only (the key never appeared in stored stack metadata),
27-
so it is recorded as a semantic migration on the protocol-18 chain step
27+
so it is recorded as a semantic migration on the protocol-17 chain step
2828
(`batch-options-validate-only-retired`) — a TODO for API callers, not a stack
2929
conversion.
3030

0 commit comments

Comments
 (0)