Skip to content

Commit 4e92c58

Browse files
committed
Merge origin/main (e35c40a) into claude/issue-21094-prod-deps-group
Claude-Session: https://claude.ai/code/session_018gA1pE6eJtwHhqx72G8U9X Co-authored-by: Claude <noreply@anthropic.com>
2 parents a2d5d53 + e35c40a commit 4e92c58

65 files changed

Lines changed: 5614 additions & 954 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.

‎.changeset/20281-connector-sync-moved-to-mapping.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,16 @@ declared-connector item carried neither key, and the def a provider registers is
2525
own. The `latest_wins` and `soft_delete` defaults read as configured policy and did
2626
nothing; the platform has no soft delete.
2727

28-
**Added (declared, not yet executed):** `mapping.connectorSource` — the pull
28+
**Added:** `mapping.connectorSource` — the pull
2929
binding on the target side, beside the mapping's existing `targetObject`,
3030
`fieldMapping`, `mode` and `upsertKey`: `connector` (a `rest` or `openapi`
3131
connector instance), `action` (the action that reads the records), optional
3232
`input`, optional `recordsPath`, and an optional `watermark` (`field` on the
3333
record, `param` on the request) for a timestamp-incremental pull. Version 1 is a
3434
one-way pull. It carries no cadence (a `job` sets that), no credential (the
35-
connector instance holds it) and no delete or conflict policy. Nothing executes it
36-
in this release, and `os validate` / `os build` warn when it is authored.
35+
connector instance holds it) and no delete or conflict policy. The connector sync
36+
executor, `@objectstack/service-automation`'s `pullConnectorSource` (#20919), reads
37+
it; nothing schedules a pull until the `job` stage lands.
3738

3839
### FROM → TO
3940

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the analytics field-level read gate refuses a cube member whose `sql` names no field, instead of letting the query run (#20965)
6+
7+
Clause-②: no
8+
9+
**What changed.** Where the analytics field-level read gate judges a cube's
10+
object (a security service is registered and gives a field answer for that
11+
object), a query that names a cube member whose `sql` is neither a column
12+
reference (a field of the cube's object, or a relationship path ending in one)
13+
nor `'*'` is now refused `403 PERMISSION_DENIED` on
14+
`POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql`, before either
15+
strategy runs. Whatever
16+
the caller may read, the member is refused. That covers an expression member
17+
of a cube that reached the service without the spec's parse (the cube
18+
registry never parses: `analyticsCubes` and `AnalyticsServicePlugin({ cubes })`
19+
arrive as written), a declared member with no `sql` string, and a member the
20+
query names itself that is not a column reference. The gate used to stand down
21+
on such a member, because it names no field, and the native-SQL strategy then
22+
compiled it into its statement as written: a read of fields no permission
23+
verdict was reached for. The refusal names the member and the object, and
24+
never the member's `sql`.
25+
26+
**What is not affected.** A member that is a column reference is judged by the
27+
field it resolves to, as before. A `count` over `'*'` names no field and is
28+
served. A deployment with no security service, and an object the security
29+
service gives no field answer for, apply no field-level check, as before. The
30+
spec's parse already refuses an expression member, so a cube that parses is
31+
unaffected.
32+
33+
**If a widget stopped answering,** its cube carries an expression member from
34+
before the parse refused one. Re-author the member as a column reference, or
35+
declare the derived value on an ADR-0021 dataset: a conditional count or sum
36+
is a dataset measure with its own `filter`, and a ratio of measures is
37+
`derived: { op: 'ratio', of: [...] }`.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: a per-aggregation `filter` and a `having` refuse a non-boolean `$exists` / `$null` with `INVALID_FILTER` / 400, in the words every driver's `where` refuses it in, instead of reading `$exists` by truthiness and dropping `$null` (#20981)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered filter-query-face-comparands-refused-at-save) this narrows the engine's own in-process evaluator to the rule that registered entry already records: its reason states that every query face refuses a non-boolean $null / $exists flag, its surface names a query having, the data-engine aggregate call's having and an aggregation filter among the carriers, and its replacement is this change's whole migration (a flag is the boolean itself; $null true is "has no value", $exists true is "has a value"). This change makes that statement true on the per-aggregation filter and having positions, which the engine evaluates itself after the driver. No authorable key, spelling, export or published type moves, and no stored row is read or rewritten; a stored filter carrying such a flag is already refused when it is saved, by that entry. -->
10+
11+
**BREAKING**: this narrows what `aggregate` accepts in two positions, `aggregations[i].filter` and `having`, on every driver. A `$exists` or `$null` comparand that is not a boolean (a string such as `"false"`, a number, `null`, an array) is now refused with `INVALID_FILTER` / 400, before any driver is asked for a row, so an empty table refuses it too, at any depth under `$and` / `$or` / `$not`. A plain object or `undefined` there is refused first by the comparand-type check, in its own words, as before; a `{ $field }` reference there, already refused as a reference outside a scalar comparison, is now refused in this entry's words. The published `applyInMemoryAggregation(rows, ast, timezone, fields)` narrows the same way, per row: it throws the same refusal for a row its per-aggregation filter judges on the flag, with or without a `fields` map (an empty `rows` array, or a row a `$or` branch settles first, is not judged there; `engine.aggregate` judges the whole filter once before any row). It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**Why a refusal.** `FieldOperatorsSchema` declares both flags as booleans, and every driver's `where` refuses any other comparand. The engine evaluates a per-aggregation `filter` and a `having` itself, and it read one anyway. Measured through `engine.aggregate` on the in-memory driver and on `SqlDriver` (SQLite), with identical answers: `$exists` was read by truthiness, so `"yes"`, `1` and the string `"false"` selected the rows and groups WITH a value, and `0` / `null` the ones without; and `$null` tested only `true` / `false`, so any other value constrained nothing, and every row and every group came back.
14+
15+
**What an author sees now.** The message `driver-sql` gives the same flag, beginning `Operator "$exists" on field "FIELD" requires a boolean comparand (true or false).`, naming what arrived and the position (`aggregations[1].filter.stage.$exists`, `having.stage.$null`). Unlike a `where` on `SqlDriver`, the field and the value are not withheld: a per-aggregation `filter` and a `having` never carry a merged read scope.
16+
17+
**What to write instead.** Write the boolean itself. `"$exists": true` and `"$null": false` match a field that has a value; `"$exists": false` and `"$null": true` match one that has none.
18+
19+
**Who is affected.** A caller that reaches `engine.aggregate` without the REST query door's schema parse (server-side code, a flow or hook, the analytics bridge that lowers a dataset measure's filter into an aggregation filter, a host calling `applyInMemoryAggregation` directly) and read the count as a real answer. `POST /api/v1/data/:object/query` already refused all three positions with 400 `VALIDATION_FAILED` before the request reached the engine, and still does.
20+
21+
**Unchanged.** `$exists: true` / `false` and `$null: true` / `false` answer exactly as before, on both positions. `$empty` and every other operator, and `where`.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
feat(spec): discovery reports which optional `/auth` route families are mounted, starting with the better-auth admin family (`authFamilies.admin`) (#21046)
8+
9+
Clause-②: yes
10+
11+
**New key.** `DiscoverySchema` declares an optional `authFamilies` block, `{ admin: boolean }`. `admin` says whether the better-auth admin family (`{routes.auth}/admin/*`: `list-users`, `set-role`, `update-user`, `ban-user`, …) is mounted on this deployment. On a deployment that does not enable the admin plugin those routes answer a plain `404`, the same as a mistyped path, so a caller checks `authFamilies.admin` before building a URL into the family. `@objectstack/spec/api` also exports the block's schema (`AuthFamiliesSchema`, type `AuthFamilies`) and its reader, `readAuthFamilies(authService)`.
12+
13+
**Same answer as `/auth/config`.** The value is the auth service's own `getPublicConfig().features.admin`, the object `GET /api/v1/auth/config` serves. Both discovery producers read it through `readAuthFamilies`: `getDiscovery()` in `@objectstack/metadata-protocol` (served by `@objectstack/rest` at `GET /api/v1/discovery`) and `getDiscoveryInfo()` in `@objectstack/runtime` (served at `GET /.well-known/objectstack`). Neither re-derives whether the admin plugin is on, so on one boot the two documents and `/auth/config` agree. On a stock boot `authFamilies.admin` is `false`. With the admin plugin on (`plugins.admin: true`, or SCIM, which forces it on) it is `true`.
14+
15+
**When the key is absent.** A producer that cannot read the answer emits no `authFamilies`, rather than a guessed `false`. That happens when no `auth` service is registered (then `routes.auth` is absent too), when the registered service has no `getPublicConfig()`, or when that call throws (`/auth/config` answers `500 AUTH_CONFIG_ERROR` in that state). Treat an absent block as "not known to be mounted".
16+
17+
**What did not change.** No existing key, route or status moved. The unmounted admin routes still answer a plain `404`.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: a public form's lookup picker searches and sorts by the first display field the caller may query, so a picker whose first display field is masked for its caller serves its rows instead of answering 403 (#21062)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a change of which display field the public lookup picker (`GET /forms/:slug/lookup/:field`) searches and sorts by: the first display field the caller may query, by the security service's answer, where it used to be the first display field. No authorable key, spelling, export or stored shape moves: `@objectstack/rest` exports nothing new and nothing less, `FormFieldPublicPickerSchema` keeps parsing every value it parsed, and no stored row is read or rewritten. A picker's `displayFields` keep their meaning as the projected fields. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers which field a picker keys on (not `already-registered`); and the change is route behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this widens what the public lookup picker serves and narrows it in one composition. The narrowing: with a security service that lacks `ISecurityService.getQueryableFields`, or that answers no answer for the object, the picker passes over every display field whose declaration carries a `maskingRule`, for every caller, including a caller the rule is lifted for. So its search and order move to the next display field that declares no rule, and a picker whose display fields all declare a rule is refused `403 PERMISSION_DENIED` without the engine being asked, where it used to be served. The security service this repository ships implements the method, so a deployment using it is not narrowed. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What changed.** The public lookup picker (`GET /forms/:slug/lookup/:field`)
14+
matches the visitor's search and orders its rows by one key. That key used to
15+
be the first entry of `publicPicker.displayFields`. It is now the first entry
16+
the caller may query on, as the security service answers it
17+
(`ISecurityService.getQueryableFields`). A field whose masking rule applies to
18+
a caller is served to that caller masked, and the engine refuses to search or
19+
sort on it with `403 PERMISSION_DENIED`. A picker whose first display field
20+
declares such a rule therefore answered `403` to every caller the rule applies
21+
to, on every request. It now serves its rows, sorted and searched on the next
22+
display field the caller may query. The masked field is still returned in each
23+
row, masked, as before.
24+
25+
**When no display field is queryable** for the caller, the picker answers
26+
`403 PERMISSION_DENIED` with the engine's refusal for those fields, without
27+
running a query.
28+
29+
**Unchanged.** A picker with no masked display field, and a caller the masking
30+
rule is lifted for, keep the first display field as the key, with the security
31+
service this repository ships. A deployment with no security service keeps the
32+
first display field.
33+
34+
**What to do.** Nothing. To choose the field a picker searches when its first
35+
display field is masked for some of its callers, list a field those callers may
36+
query among `displayFields`: the first such entry is the one searched and
37+
sorted on. A picker whose only display fields are masked for its callers is
38+
refused, so give it one they may query.
39+
40+
**What to do after upgrading, if your security service predates `getQueryableFields`.**
41+
Implement `getQueryableFields` on it: it answers which fields a caller may filter,
42+
sort, group or aggregate by, and the picker then keys on the first display field
43+
in that answer. Until it does, give each picker at least one display field that
44+
declares no `maskingRule`, or the picker is refused for every caller.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
fix(plugin-audit): an activity row serves a parent field's value only to a reader the security service serves that field (#21081)
6+
7+
Clause-②: no
8+
9+
The activity stream's CRUD mirror composes each row once, at write time, as the system. The row's summary, its record label and its recorded change can carry the values of the parent record's fields. The activity read gate keeps a row for every reader who can read the parent record, so a reader who may not read one of that record's fields was served the field's stored value through the row. This held for a field served masked to the reader, a field gated by `requiredPermissions` the reader does not hold, and a field a permission set the reader holds marks non-readable. The data plane answered the same reader masked or without the key.
10+
11+
The rows are now redacted at read time, keyed on the reading caller, through the security service's own answer: the read projection intersected with the query-side answer, whose difference the contract defines as exactly the fields served masked. The recorded change drops every key the reader is not served. The summary and the record label are each served whole or dropped whole: the mirror now declares, in the row, which parent fields each was composed from, and a text composed from a field the reader is not served is dropped. A text composed only from served fields is kept. The full row stays at rest, and system reads are unchanged.
12+
13+
Rows written before this release carry no such declaration. Their summary and record label are served only to a reader who is served every field of the parent record, until the rows age out with the stream's retention. Rows an app writes itself are served as written, except that a recorded change in the mirror's shape is narrowed the same way.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`ERROR_CODE_LEDGER['@objectstack/service-automation']` now lists `MAPPING_NOT_FOUND` and `UNSUPPORTED_TRANSFORM`, the two registered codes the connector sync executor (`pullConnectorSource`) stamps onto `ConnectorPullError.code` (#21106).
6+
7+
Clause-②: yes
8+
9+
Provenance, not identity. The per-package face of `ERROR_CODE_LEDGER` changes in this release in two steps, and neither changes the `ErrorCode` union, the wire or any HTTP answer:
10+
11+
- The bulk-import runner, the mapping pipeline and the data-error classification moved out of `@objectstack/rest` (#20919), and each code's row moved to the package that now stamps it. `@objectstack/core` gains `AMBIGUOUS_MATCH`, `BLANK_MATCH_KEY`, `NO_MATCH`, `SUMMARY_RECOMPUTE_FAILED` and `UNSUPPORTED_TRANSFORM`. A new `@objectstack/types` key lists `CONCURRENT_UPDATE`, `ERR_DATASOURCE_UNAVAILABLE` and `UNIQUE_VIOLATION`. `@objectstack/rest` no longer lists those seven, because it stamps none of them now; it keeps `UNSUPPORTED_TRANSFORM`, which it still stamps.
12+
- `@objectstack/service-automation` gains the two rows above. Both codes were already registered, under `@objectstack/rest` (and `UNSUPPORTED_TRANSFORM` under `@objectstack/core` as well).
13+
14+
So a consumer reading `ERROR_CODE_LEDGER['@objectstack/rest']` sees seven fewer entries, and one reading the `@objectstack/core`, `@objectstack/types` or `@objectstack/service-automation` key sees the new ones. Nothing to migrate: every code keeps its wire value and its status.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
'@objectstack/driver-sql': patch
4+
---
5+
6+
feat(driver-turso): the remote transport issues `auto_number` values (#21113)
7+
8+
Clause-②: yes (widening)
9+
10+
A `create()`, `bulkCreate()` or `upsert()` on the Turso REMOTE transport that
11+
leaves an `autonumber` field empty (`undefined`, `null` or `''`) now gets a
12+
generated value. It used to be refused with `NOT_IMPLEMENTED` / 501, so on a
13+
hosted tenant database — which is on this transport — no object declaring an
14+
`auto_number` field could get a new record at all. Nothing is declared anew in
15+
the spec or in the package exports; `supports.autonumber` stays `true` and is
16+
now honoured.
17+
18+
- The value comes from the same persistent `_objectstack_sequences` counter the
19+
local and embedded-replica transports use, rendered by the same format rules
20+
(`autonumberFormat` / `format`, organization scope, date and `{field}`
21+
tokens), bootstrapped from the table's highest existing value by the same
22+
reading, and re-seeded the same way after rows land above the counter by a
23+
seed replay or import. A remote driver and an embedded replica of one
24+
database draw from one counter row.
25+
- The counter moves in one statement over the connection (`UPDATE … RETURNING`,
26+
or on a cold counter `INSERT … ON CONFLICT (key_hash) DO UPDATE … RETURNING`),
27+
so writers in different processes never draw the same number.
28+
- An `upsert()` that merges into an existing row keeps the number already in
29+
the row; a row that carries its own number is written unchanged.
30+
- A `_objectstack_sequences` table in the pre-`key_hash` shape is refused in
31+
remote mode with `DATABASE_ERROR` / 500 and the remedy in the message (open
32+
the database once through the local or embedded-replica transport, which
33+
migrates it); remote mode does not migrate it and does not key by the legacy
34+
rule.
35+
- `RemoteTransport.upsert()` takes an optional fifth argument naming columns
36+
that are written on insert and left alone on merge.
37+
38+
`@objectstack/driver-sql`: the sequence rules a second transport shares are
39+
now `protected` members of `SqlDriver` (`resolveSequenceTenantId`,
40+
`defineSequencesTable`, `maxAutonumberCounter`, `escapeLikePrefix`,
41+
`sequencesTableName`, `autoNumberCollisionRetries`). No export is added and no
42+
behaviour changes on any dialect.

0 commit comments

Comments
 (0)