Skip to content

Commit fb56614

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/app-metadata-validation-assessment-xskbcg
# Conflicts: # packages/spec/api-surface.json
2 parents e90e4d1 + 9aa5510 commit fb56614

100 files changed

Lines changed: 6445 additions & 98 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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/service-storage": minor
5+
"@objectstack/objectql": minor
6+
"@objectstack/cli": minor
7+
---
8+
9+
feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617)
10+
11+
The ADR-0104 file-as-reference migration ships as a command a deployment runs
12+
against its own database, and the deployment-level flag it records is what may
13+
later authorise irreversible behaviour — never the platform version.
14+
15+
```bash
16+
os migrate files-to-references # dry run: reports, writes nothing
17+
os migrate files-to-references --apply # converts, verifies, records the flag
18+
```
19+
20+
The run backfills legacy file-field values (inline metadata blobs, own-resolver
21+
URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership
22+
ledger against what records actually hold, and — only on an `--apply` run whose
23+
reconciliation reports **zero blocking discrepancies** — records
24+
`sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`.
25+
26+
**Why a flag rather than a release note.** ObjectStack is a development
27+
platform: third-party deployments upgrade on their own schedule and their data
28+
is not observable by anyone else, so no release-side soak can vouch for them.
29+
The evidence has to be produced where the data is. Consequences:
30+
31+
- Installing a new version never starts deleting bytes. Running the migration
32+
and passing its self-check is the consent.
33+
- Not run, or not passed → files are retained forever. Wasted storage, zero
34+
data loss.
35+
- A later failing run **clears** `verified_at`: a deployment whose data has
36+
drifted closes its own gate.
37+
- A dry run writes nothing at all — not the conversions, and not the flag,
38+
even when the self-check would pass.
39+
- External URLs stay advisory. They are not `sys_file`s, so they can never
40+
enter collection; whether to remodel them as a `url` field is the app
41+
author's decision (ADR-0104 R7), not a gate.
42+
43+
Ships alongside:
44+
45+
- `@objectstack/spec``DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`,
46+
and the single `isDataMigrationFlagVerified` predicate both future consumers
47+
(collection #3459, strict value-shape #3438) read, so the two gates cannot
48+
disagree about the same fact.
49+
- `@objectstack/platform-objects` — the `sys_migration` object plus
50+
`readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`.
51+
Reads fail toward "not verified": a gate that cannot read its evidence stays
52+
closed.
53+
- `@objectstack/objectql` — a read may now opt out of file-reference expansion
54+
via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's
55+
bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to
56+
their expanded form before the reconciliation sees them, which reports held
57+
references as absent — noisy `stale_owner` findings, and a missed
58+
`unowned_reference` would have been a false pass of the collection gate.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): scope the dimension-label lookup to the referenced object's RLS (#3602)
6+
7+
When a dataset groups by a `lookup`/`master_detail` dimension, analytics resolves
8+
the grouped FK ids to the related record's display name via a per-record read
9+
(`group by id`) dressed as an aggregate. That read carried **no read scope**, so
10+
it revealed related-record display names whenever the referenced object's RLS is
11+
stricter than the base object whose rows carry the id — a user could see a name
12+
the referenced object's own RLS would hide. (Same-object and looser-referenced
13+
cases were already safe because the ids come from the post-#3597 scoped
14+
aggregate; this closes the stricter-referenced case.)
15+
16+
The label lookup now applies the **referenced object's own** read scope — bound
17+
to the request via the same `getReadScope` provider the aggregate path uses,
18+
composed with `$and` (never key-merge) so it can't be displaced by the id
19+
predicate. Fail-closed: if that object's scope can't be resolved, the dimension's
20+
labels are skipped (the raw id renders) rather than fetched unscoped. No behaviour
21+
change when no read-scope provider is configured.
22+
23+
Internal `DimensionLabelDeps.fetchRecordLabels` gains an optional `scope` argument
24+
and `resolveDimensionLabels` an optional `resolveScope` resolver; both are
25+
service-analytics-internal (no spec/contract change).
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/service-analytics": patch
4+
"@objectstack/rest": patch
5+
---
6+
7+
feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588)
8+
9+
Three presentation options were accepted by the metadata layer and then dropped
10+
by the analytics query builder. They reached no SQL, produced no error, and the
11+
only way to notice was to read the `sql` a dataset response echoes — so a
12+
dashboard could declare `dateGranularity: 'month'` and quietly render one bar
13+
per record.
14+
15+
- **`dateGranularity` now buckets.** `DatasetSelection` gained an optional
16+
`dateGranularity`, applied to every selected `date` dimension. Precedence per
17+
dimension: an explicit `timeDimensions` granularity, then the selection's,
18+
then the dataset dimension's own default. A widget can bucket a trend by month
19+
without the dataset committing every other consumer to that granularity.
20+
- **`order` / `limit` / `offset` now apply on every path.** They are applied to
21+
the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo`
22+
columns attach, and after derived measures are computed — so a derived measure
23+
is a valid sort key and the ObjectQL aggregate path (which has no ordering
24+
grammar, and which native SQL hands every date-bucketed query to) orders
25+
identically to native SQL. A single-query selection still pushes the window
26+
down into the statement. An `order` key that names nothing the selection
27+
projects is now rejected (400) rather than silently ignored.
28+
- **`limit` is deterministic.** Without an `order`, a limit orders by the
29+
selected dimensions first, so it truncates a reproducible window instead of an
30+
arbitrary subset.
31+
- **Widget `options` is a contract again.** The four query-affecting keys
32+
(`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are
33+
declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is
34+
an author-time error. The bag stays open — renderer extras (`icon`, `columns`,
35+
`striped`, …) pass through untouched.
36+
37+
Two latent bugs surfaced while fixing the above and are fixed here too:
38+
39+
- `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped
40+
supplementary query selects one measure, so an inherited `ORDER BY` named a
41+
column it never selected, and an inherited `LIMIT` truncated it before the
42+
merge — dropping rows from the assembled grid. Nothing hit this only because
43+
nothing passed `order`.
44+
- The `compareTo` pass built its query by hand and skipped granularity
45+
resolution, so a month-bucketed primary grid was merged against raw-timestamp
46+
comparison rows. No dimension key matched and every `<measure>__compare`
47+
column came back empty.
48+
49+
`ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`,
50+
`WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined).
51+
Previously the `sql` field simply vanished from the response whenever a query
52+
was date-bucketed, leaving an author unable to tell "not implemented" from "this
53+
strategy doesn't report".
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(auth): provision the better-auth 1.7 columns `sys_team` / `sys_team_member` / `sys_two_factor` were missing (#3624)
7+
8+
better-auth 1.7.0-rc.1 added fields to three models that the platform objects
9+
never provisioned and `auth-schema-config.ts` never mapped. Because an unmapped
10+
field keeps its camelCase name, the adapter emitted columns no table had:
11+
12+
| model | field | column now provisioned |
13+
|:---|:---|:---|
14+
| `team` | `memberCount` | `sys_team.member_count` |
15+
| `teamMember` | `membershipKey` | `sys_team_member.membership_key` |
16+
| `twoFactor` | `failedVerificationCount` / `lockedUntil` | `sys_two_factor.failed_verification_count` / `locked_until` |
17+
18+
The team pair broke org creation outright. The organization plugin's team
19+
sub-feature is on by default, so `POST /api/v1/auth/organization/create`
20+
auto-creates a default team — and that insert died with `table sys_team has no
21+
column named memberCount` *after* the organization row had already committed.
22+
Callers got an HTTP 500 on top of a half-created org: a real org row with no
23+
default team behind it. Every multi-org deployment's create-org flow hit this.
24+
25+
The two-factor pair broke the 2FA lockout path the same way: better-auth
26+
guard-increments `failedVerificationCount` on each wrong code and stamps
27+
`lockedUntil` past the threshold, so a wrong code 500'd instead of being
28+
counted. All four columns are better-auth's own state — provisioned, readable,
29+
and never written from the ObjectStack side.
30+
31+
Existing environments pick the columns up through the driver's additive schema
32+
sync; no data migration is needed. `member_count` backfills to 0 and
33+
better-auth's own `syncTeamMemberCount` reconciles it on the next membership
34+
change, and `membership_key` stays null on pre-upgrade rows, which better-auth
35+
tolerates by falling back to the `(team_id, user_id)` pair.
36+
37+
A new drift gate (`better-auth-schema-parity.test.ts`) now asserts that every
38+
column the installed better-auth version can write exists on the platform
39+
object backing it, across the auth manager's whole model surface. The ADR-0092
40+
D7 guard only ever caught *collisions* between our extension fields and
41+
better-auth's, so a bump that adds a brand-new field passed the build and failed
42+
at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId`
43+
regression. The next one fails the build instead.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
test(client): close the route audit's reverse direction — every SDK URL must match a route some surface mounts (#3642)
6+
7+
The capstone of the #3563 route audit. The dispatcher (#3563), REST (#3587) and
8+
service-mount (#3636) ledgers all run server → client: enumerate what a surface
9+
mounts, demand a reviewed disposition, and for `sdk` rows demand the named
10+
client method exists. None of them asked the reverse question — does the URL
11+
the client *builds* match anything a server *mounts*? — so a method could name
12+
a real function, carry a green ledger row, and 404 everywhere.
13+
14+
That shipped four times, found one at a time by hand: `analytics.explain` and
15+
`analytics.meta` (#3584), `meta.getView` (#3611), and `i18n.getTranslations` /
16+
`getFieldLabels` (#3636) — the last pair having carried green `sdk` rows since
17+
tranche 1.
18+
19+
`client-url-conformance.test.ts` drives every method on a real client with a
20+
recording `fetch` and matches each captured URL against the union of all four
21+
ledgers. A real drive rather than a hand-written "method X targets route Y"
22+
table, because such a table is an assertion *about* the code that the code can
23+
drift away from — the exact failure being fixed. Mutation-checked: re-injecting
24+
the #3636 dialect bug fails the suite.
25+
26+
The sweep's own completeness is asserted, since that is what rots silently — a
27+
new method must be driven or declared `NON_HTTP` with a reason; a driven method
28+
emitting zero requests fails (stale placeholder args are how a sweep quietly
29+
stops covering anything); a URL containing `undefined` fails; and the
30+
`__api-endpoint` `(unmatched)` catch-all is excluded from the pattern set so it
31+
cannot match everything and make the suite vacuous.
32+
33+
196 of ~219 methods matched. Two bounds are reported rather than papered over:
34+
`/api/v1/cloud/*` (23 `projects.*` methods) belongs to the sibling `cloud` repo
35+
and is exempt by prefix, bounded so no other namespace can use it (#3655); and
36+
60 of ~196 matched calls rest only on a `**` prefix claim rather than a
37+
resolvable route — 54 of those on `* /auth/**` — a count the guard ratchets so
38+
it can only shrink (#3656).
39+
40+
No runtime change: this is a guard plus the ledger-header and audit-doc notes
41+
recording what it does and does not cover.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): export emits the projected header row on an empty result set (#3547)
6+
7+
`GET /data/:object/export` wrote a zero-byte file whenever the query matched no
8+
rows — the header was only ever written alongside the first data chunk. With the
9+
`getReadableFields` column projection the readable column set is derived from
10+
schema + context, so it is known even when no rows come back: an empty CSV/xlsx
11+
export now carries the exact readable header, which also makes it a usable
12+
import template.
13+
14+
The header is emitted only when the column set is AUTHORITATIVE — the security
15+
service's readable projection, or an explicit `?fields=` request. When the header
16+
is schema-derived and the projection was unavailable, the export stays headerless
17+
as before: the masked-row fallback has no rows to narrow with, and writing the
18+
full schema header would name FLS-hidden columns. `header=false` still suppresses
19+
the header in every case.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): `onInvitationAccepted` host seam — better-auth's
6+
`afterAcceptInvitation` forwarded to the host (ADR-0105 D8 prerequisite)
7+
8+
An invitation may carry placement intent (target business unit + positions,
9+
extension fields on `sys_invitation` per the ADR-0092 whitelist), but there
10+
was no server-side seam to apply it when the invitation is accepted —
11+
better-auth's org-plugin models don't fire core `databaseHooks` (framework
12+
#3541 D8 note).
13+
14+
`AuthManagerConfig.onInvitationAccepted` mirrors `onOrganizationCreated`:
15+
invoked from `organizationHooks.afterAcceptInvitation` with the mapped ids
16+
(`invitationId`, `organizationId`, `userId`, `memberId`, `role`, `email`)
17+
plus the RAW `invitation` / `member` rows so a host reads its own extension
18+
columns without a second query. Failure-isolated — acceptance never rolls
19+
back on a side-effect miss; hosts needing effectively-atomic placement
20+
should make the callback idempotent and reconcile on retry.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/cli": minor
5+
"@objectstack/rest": patch
6+
---
7+
8+
feat(spec): resolve page metadata i18n — `page:header` title/subtitle (#3589)
9+
10+
Custom system pages authored as metadata (Installed Apps, Cloud Connection,
11+
Connect an Agent) hard-code their `page:header` copy in
12+
`properties.title` / `properties.subtitle`. Every other metadata type is
13+
localized at the REST boundary, but `page` was not: the `pages` namespace
14+
existed only on `AppTranslationBundleSchema` — a schema no runtime reads —
15+
with no resolver behind it, so those headers stayed English in every locale
16+
while the matching nav labels translated correctly.
17+
18+
- `TranslationDataSchema` (the shape the i18n service actually serves) gains a
19+
`pages` namespace: `pages.<name>.{label,description,title,subtitle}`.
20+
- New `translatePage` in `@objectstack/spec/system` translates a page's own
21+
`label` / `description` and overlays `title` / `subtitle` onto every
22+
`page:header` in the page's regions. Registered in
23+
`translateMetadataDocument`, so it rides the existing read path.
24+
- `page` added to the REST boundary's `TRANSLATABLE_META_TYPES`. Locale
25+
extraction, the locale-keyed ETag, and `Vary: Accept-Language` already
26+
covered every metadata type — no new plumbing.
27+
- `objectstack i18n extract` now emits page entries, including the
28+
`page:header` copy, so the new namespace is not invisible to the tooling.
29+
- zh-CN / ja-JP / es-ES translations shipped for the three Setup pages, plus
30+
the missing `nav_cloud_connection` / `nav_connect_agent` nav labels (these
31+
existed only in zh-CN).
32+
33+
Header copy is keyed by **page name**, not by component id: `page:header`
34+
instances carry no stable id. `title` falls back to `pages.<name>.label`, since
35+
a page's header title and its nav label are normally the same string.
36+
37+
Authoring is unchanged and English literals stay in metadata as the fallback —
38+
a page with no `pages` entry renders exactly as before. Consumers of
39+
`@object-ui` need no change: pages arrive already localized from the server.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
"@objectstack/service-i18n": patch
4+
"@objectstack/client": patch
5+
---
6+
7+
fix(client,service-i18n): ledger the autonomously-mounted service routes, and repair the two i18n calls that reached nothing (#3636)
8+
9+
Tranche 3 of the #3563 route audit — the last un-audited server surface. The
10+
dispatcher ledger (#3563) and the REST ledger (#3587) each stop at their own
11+
package boundary, and two services mount routes outside both: they reach for
12+
the `http-server` service and register straight on `IHttpServer`, so neither
13+
`RouteManager` nor `RestServer.getRoutes()` has ever seen them. That left the
14+
SDK's entire storage surface, plus all of i18n, in the pre-#3563 posture:
15+
expressed, working, guarded by nothing.
16+
17+
**Ledgers + guards.** `storage-route-ledger.ts` (10 routes) and
18+
`i18n-route-ledger.ts` (3) sit next to the registrars that mount them, each
19+
enumerated for real — the registrar runs against a capturing mock
20+
`IHttpServer` and its registration calls *are* the route set, so a new route
21+
lands with a reviewed disposition or fails CI. The client half is
22+
`packages/client/src/service-route-ledger-coverage.test.ts`; ledgers cross the
23+
boundary as relative source imports, never a service→client package edge.
24+
25+
**Two wire-level 404s fixed.** `i18n.getTranslations` sent
26+
`/i18n/translations?locale=xx` and `i18n.getFieldLabels` sent
27+
`/i18n/labels/:object?locale=xx`, while every serving surface — service-i18n's
28+
mounts, the dispatcher's HTTP mounts, and the `plugin-rest-api.zod.ts`
29+
contract — mounts only the path form. Neither call could ever be answered.
30+
Both had carried a green `sdk` row in the dispatcher ledger since tranche 1,
31+
because that guard asks whether the client *method* exists, not whether it
32+
speaks a URL anything mounts. The client now sends the path dialect, the same
33+
resolution #3611 gave `meta.getView`, and a new suite drives the real client
34+
at a real router so a revert cannot pass quietly.
35+
36+
**One response-shape fix.** service-i18n's success bodies omitted the
37+
`success` flag that `ObjectStackClient.unwrapResponse` keys on, so the SDK
38+
returned the raw `{ data: … }` wrapper against that provider while returning
39+
the declared unwrapped shape against the dispatcher — one method, two shapes,
40+
decided by which plugin mounted the route. Its three handlers now emit the
41+
`{ success: true, data }` envelope the `i18n` route group declares. `data` did
42+
not move, so direct body readers are unaffected.
43+
44+
Storage audited clean: 7 routes SDK-expressed, 3 reviewed `server-only` (the
45+
browser capability URL objectql stamps into file-field payloads, and the two
46+
local-driver loopbacks). The chunked-upload family, flagged for triage, turned
47+
out fully expressed. Both ledgers ratchet `gap` and `mismatch` at zero.
48+
49+
Filed, not fixed: `GET {base}/_local/file/:key` is built by three call sites
50+
and mounted by none (#3641); the cross-surface URL conformance guard that would
51+
have caught all of the above mechanically is the capstone (#3642).

0 commit comments

Comments
 (0)