Skip to content

Commit 61e5f34

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14967-governed-review-request-patrol
2 parents f69210e + ed9d876 commit 61e5f34

10 files changed

Lines changed: 603 additions & 48 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
fix(core): narrow the operation-private-keys pin's scanner to `.ts`, so it judges exactly the population turbo re-runs it for (#15090)
6+
7+
`packages/core/src/security/operation-private-keys.pin.test.ts` filtered its
8+
candidate set with `/\.tsx?$/``.ts` **and** `.tsx` — while this package's
9+
declared radius in the cross-package declaration table is a `packages/**`
10+
subtree glob ending in `.ts`. So the pin judged a population **strictly wider**
11+
than the one either scoping layer of `check:cross-package-test-inputs` knows
12+
about: Layer A never unions this package into the test shard when a `.tsx` file
13+
changes, and Layer B never moves the `test` task's cache hash for one. A `.tsx`
14+
file under `packages/` declaring its own `OPERATION_PRIVATE_KEY_PREFIX` or
15+
`withoutOperationPrivateKeys` was therefore scanned by the pin and invisible to
16+
CI's scoping — landing on `main` with every PR green and then reddening whichever
17+
unrelated PR next touched a `.ts` file. That is the #7802 shape the declaration
18+
table exists to close, one extension wide.
19+
20+
Repaired by narrowing the **scanner**, not by widening the **glob** — and that
21+
asymmetry is measured rather than assumed. On `b548e438d`, adding a `.tsx` glob
22+
to this package's roster entry and re-deriving `check:cross-package-test-inputs`'
23+
watch hints flips the dispatch-gates self-test case *"nor a .tsx test file inside
24+
it"* from true to false, with the added glob itself as the covering hint. That
25+
case is a live specimen for "a test class the hint route cannot reach", so the
26+
red is real and re-pointing it is a decision in another lane, not a fixup.
27+
28+
What the boundary costs, measured on the pin's own surface (tracked **plus**
29+
untracked, ignored paths excluded) at `b548e438d`: **5408** `.ts` files scanned,
30+
8 of them mentioning a guarded symbol; **8** `.tsx` files excluded, **0** of them
31+
mentioning either symbol. The loss is empty today — and that reading is no longer
32+
transcribed and trusted. A new case re-measures it on every run: it asserts the
33+
excluded `.tsx` population is non-empty (so the boundary is an exclusion and not
34+
an empty tree describing itself), that the filter really drops those files, and
35+
that none of them declares either symbol. Ablation, with the restore proven by
36+
blob hash rather than by exit code: re-widening the scanner reddens it while the
37+
offender assertion stays green — which is precisely the failure mode, since a
38+
wider scanner reads as coverage CI never runs — and planting a `.tsx`
39+
redeclaration reddens it with a message that says the choice is a second-gate
40+
trade, not a one-line widening.
41+
42+
The correspondence between scanner and glob is now stated at **both** ends: the
43+
pin's header and the declaration table's entry for this package. No published
44+
surface moves — the only source file edited is a test.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-memory': minor
3+
---
4+
5+
fix(driver-memory): `find()`, `findOne()` and `create()` publish their declared types (#14435)
6+
7+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the same shape #13878 landed on `update()` / `upsert()` one door over, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level).
8+
9+
`IDataDriver` has always declared `Promise<Record<string, unknown>[]>`, `Promise<Record<string, unknown> | null>` and `Promise<Record<string, unknown>>` on these three doors. The emitted `.d.ts` published `Promise<any[]>`, `Promise<any>` and `Promise<Record<string, any>>`: the return types of `find` and `findOne` were INFERRED through the backing store's `any[]` rows (`private db: Record<string, any[]>` to `getTable()`), and `create` carried an explicit annotation that itself spelled `Record<string, any>`. They are now declared as the contract declares them.
10+
11+
What this asks of a consumer holding a concrete `InMemoryDriver`: a caller that reads fields off a `findOne()` result narrows the `null` arm first — the arm the driver has always been able to answer with (`results[0] || null`) and that no caller was ever asked to handle; and a caller that leaned on `any` to read a member off a `find()` row or a `create()` result now types it, since the rows are `Record<string, unknown>`. A consumer whose receiver is typed as `IDataDriver` sees no change at all — that declaration already said this.
12+
13+
The parameters are deliberately untouched: `create(data: Record<string, any>)` stays as it is, because narrowing an INPUT would be a second, unrelated break, and method parameters compare bivariantly against the contract's `Record<string, unknown>`. No runtime behaviour changes; the store keeps its `any[]` rows, which the card measured to cascade if re-typed.
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped, no spec schema changes (this diff touches `packages/drivers/driver-memory/**` only), and nothing exists for `objectstack migrate meta` to rewrite. The obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. -->

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ These properties are available on **all** field types:
653653
| `description` | `string` | — | Field description / help text |
654654
| `type` | `FieldType` | **required** | One of the supported field types |
655655
| `required` | `boolean` | `false` | Whether the field is required |
656-
| `unique` | `boolean \| 'global' \| 'organization'` | `false` | Unique constraint **and its scope** (ADR-0120). `'organization'` = one holder per organization; `true` = that same per-organization scope (positional synonym — prefer the explicit spelling in new code); `'global'` = one holder across the whole installation; `false` = no constraint. `'tenant'`/`'org'` are rejected — the word is `'organization'`. Full rule in the `unique` doc block in [`FieldSchema`](/docs/references/data/field) |
656+
| `unique` | `boolean \| 'global' \| 'organization'` | `false` (`'organization'` on `autonumber`) | Unique constraint **and its scope** (ADR-0120). `'organization'` = one holder per organization; `true` = that same per-organization scope (positional synonym — prefer the explicit spelling in new code); `'global'` = one holder across the whole installation; `false` = no constraint. Omitted ⇒ `false`, except on an `autonumber` field, where omitted ⇒ `'organization'` — an auto-number is a business identifier, so the platform makes it unique per organization by default; write `unique: false` explicitly to opt out. `'tenant'`/`'org'` are rejected — the word is `'organization'`. Full rule in the `unique` doc block in [`FieldSchema`](/docs/references/data/field) |
657657
| `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) |
658658
| `searchable` | `boolean` | `false` | Include in search index |
659659
| `sortable` | `boolean` | `true` | Allow sorting by this field |

packages/core/src/security/operation-private-keys.pin.test.ts

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,69 @@ function git(args: string[]): string[] {
155155
return stdout.split('\0').filter((line) => line.length > 0);
156156
}
157157

158-
const isScannedSource = (path: string) => /\.tsx?$/.test(path) && !path.endsWith('.d.ts');
158+
/**
159+
* ── The EXTENSION boundary is `.ts` ALONE, and unlike the tree it is not free ──
160+
*
161+
* `.ts` and NOT `.tsx`, which is what this filter said first (`/\.tsx?$/`, one
162+
* character wide). The population this pin JUDGES has to equal the population
163+
* turbo RE-RUNS it for, and the second of those is declared somewhere else: the
164+
* radius roster entry for `@objectstack/core` in the declaration table at
165+
* scripts/cross-package-test-inputs.mjs declares a `packages/**` subtree glob
166+
* ending in `.ts`, which does not cover `.tsx`.
167+
*
168+
* A scanner wider than that glob judges files neither scoping layer of
169+
* `check:cross-package-test-inputs` can see. Layer A never unions this package
170+
* into the test shard when the diff touches one of them; Layer B never moves
171+
* this package's `test` task cache hash for one of them. So a `.tsx` file under
172+
* packages/ that declared its own copy would be scanned by this pin and
173+
* invisible to both — landing on `main` with every PR reporting green, and then
174+
* reddening whichever unrelated PR next touches a `.ts` file. That is #7802's
175+
* shape, one extension wide, and it is exactly what the declaration table
176+
* exists to prevent.
177+
*
178+
* ⛔ The repair is to narrow the SCANNER, never to widen the GLOB — the two
179+
* directions are not symmetric. Those globs are INHERITED as watch hints by
180+
* `check:cross-package-test-inputs`, and a self-test in the dispatch-gates tool
181+
* pins that no hint of that family reaches the realtime-hooks test file — the
182+
* `.tsx` one — in the client-react package, its live specimen for "a test class
183+
* the hint route cannot reach". Measured on b548e438d rather than reasoned:
184+
* with that same subtree glob ending in `.tsx` added to this package's roster
185+
* entry and that family's hints re-derived, the case flipped from true to false
186+
* and the added glob was itself the covering hint. It is a real red and not a
187+
* nuisance — the specimen is how that tool proves its residue classes are not
188+
* empty — and re-pointing it is an edit in another lane. ⇒ Extensions and glob
189+
* widen together or not at all; the sibling pin in `@objectstack/types` records
190+
* the same trade from the other side of it.
191+
*
192+
* (That specimen is named in two halves rather than as one quoted path on
193+
* purpose. `check:cross-package-test-inputs` collects quoted whole
194+
* repo-relative paths out of this file's source, COMMENTS INCLUDED, into the
195+
* roster it demands the declared globs cover — so spelling it here in one
196+
* quoted piece would demand the very `.tsx` glob this paragraph exists to
197+
* forbid. Measured the same way.)
198+
*
199+
* ⚠️ What narrowing GIVES UP, measured rather than assumed. Under packages/ on
200+
* b548e438d, over this pin's own surface (tracked PLUS untracked, ignored paths
201+
* excluded), against this pin's own two symbols:
202+
*
203+
* .ts 5408 files, 8 mention a guarded symbol <- scanned
204+
* .tsx 8 files, 0 mention a guarded symbol <- excluded
205+
*
206+
* ⇒ the loss is empty today. ⛔ That reading is not transcribed and then
207+
* trusted: the last test below RE-MEASURES it on every run, because a count
208+
* with a commit on it is honest and a count without one rots silently. The day
209+
* a `.tsx` file under packages/ declares either symbol, that case goes red and
210+
* says what the choice actually is — which is not "widen this filter".
211+
*/
212+
const isScannedSource = (path: string) => path.endsWith('.ts') && !path.endsWith('.d.ts');
159213

160214
/**
161-
* The scan surface: every `.ts`/`.tsx` file under `packages/` that a human
162-
* authored — tracked plus untracked, ignored paths (build output) excluded.
215+
* Every file under `packages/` that a human authored — tracked plus untracked,
216+
* ignored paths (build output) excluded — BEFORE the extension boundary. Split
217+
* out from `scannedFiles()` so the boundary has something to be measured
218+
* against: a filter is only an exclusion while the set it filters is non-empty.
163219
*/
164-
function scannedFiles(): string[] {
220+
function authoredPaths(): string[] {
165221
return git([
166222
'ls-files',
167223
'-z',
@@ -170,15 +226,22 @@ function scannedFiles(): string[] {
170226
'--exclude-standard',
171227
'--',
172228
'packages',
173-
]).filter(isScannedSource);
229+
]);
230+
}
231+
232+
/** The scan surface: the authored files the extension boundary above admits. */
233+
function scannedFiles(): string[] {
234+
return authoredPaths().filter(isScannedSource);
174235
}
175236

176237
/**
177-
* The subset of the scan surface that so much as mentions either identifier.
178-
* Fixed-string, so it is an exact superset of `declarationMatcher()`'s matches; the regex
179-
* still decides which of these — if any — is an actual declaration.
238+
* Every path the fixed-string prefilter returns, BEFORE the extension boundary
239+
* — so it includes the `.tsx` (and `.md`, and config) files the scan does not
240+
* judge. Split out for the same reason as `authoredPaths()`: the trade the
241+
* boundary rests on is a claim about the files it drops, and a claim about
242+
* dropped files cannot be made from the set they were dropped from.
180243
*/
181-
function filesMentioningASymbol(): string[] {
244+
function prefilterHits(): string[] {
182245
return git([
183246
'grep',
184247
'--files-with-matches',
@@ -192,7 +255,16 @@ function filesMentioningASymbol(): string[] {
192255
HELPER_SYMBOL,
193256
'--',
194257
'packages',
195-
]).filter(isScannedSource);
258+
]);
259+
}
260+
261+
/**
262+
* The subset of the scan surface that so much as mentions either identifier.
263+
* Fixed-string, so it is an exact superset of `declarationMatcher()`'s matches; the regex
264+
* still decides which of these — if any — is an actual declaration.
265+
*/
266+
function filesMentioningASymbol(): string[] {
267+
return prefilterHits().filter(isScannedSource);
196268
}
197269

198270
function declaringFiles(): string[] {
@@ -297,4 +369,73 @@ describe('the `__` operation-private-key convention has one owner (#7284)', () =
297369
},
298370
SCAN_TIMEOUT_MS,
299371
);
372+
373+
it(
374+
'the extension boundary is `.ts` alone, and the trade that buys it has not expired',
375+
() => {
376+
// The executable half of the EXTENSION boundary section on
377+
// `isScannedSource`. That paragraph is the only thing keeping the judged
378+
// population equal to the population turbo re-runs this pin for, and
379+
// #9763 is the day a radius held by prose alone came unforced by an
380+
// innocent reword. Three claims, in the order they can go false.
381+
382+
// 1. THE EXCLUSION IS REAL. `.tsx` files exist under packages/, so "the
383+
// scan sees none" is an exclusion and not an empty tree describing
384+
// itself — #4690's shape, applied to the boundary rather than to the
385+
// offender set.
386+
const excluded = authoredPaths().filter((file) => file.endsWith('.tsx'));
387+
388+
expect(
389+
excluded.length,
390+
'No `.tsx` file exists under packages/ at all, so the extension boundary '
391+
+ 'below excludes nothing and this case proves nothing. Re-measure the '
392+
+ 'trade in the header before trusting it.',
393+
).toBeGreaterThan(0);
394+
395+
// 2. …AND THE FILTER REALLY DROPS THEM. Re-widen `isScannedSource` and
396+
// this is what goes red first, in the same run that the offender test
397+
// above stays green — which is the whole point: widening the scanner
398+
// alone reads as coverage while turbo never re-runs this pin for the
399+
// files it has started to judge.
400+
expect(
401+
scannedFiles().filter((file) => file.endsWith('.tsx')),
402+
'The scan surface has grown `.tsx` files back. The declared radius for '
403+
+ 'this package covers `.ts` only, so these are judged by a pin that '
404+
+ 'CI will not re-run when they change. Narrow the filter back, or '
405+
+ 'widen the glob too — and read the header first, because the second '
406+
+ 'half of that is a trade with another gate, not a formality.',
407+
).toEqual([]);
408+
409+
// 3. THE TRADE ITSELF, re-measured rather than transcribed. The boundary
410+
// gives up naming a `.tsx` redeclaration as an offender; that costs
411+
// nothing only while no excluded file declares either symbol. The
412+
// header's counts carry a commit precisely because they rot — this is
413+
// the part that cannot.
414+
const excludedDeclarations = prefilterHits()
415+
.filter((file) => file.endsWith('.tsx'))
416+
.filter((file) => declarationMatcher().test(readFileSync(join(REPO_ROOT, file), 'utf8')));
417+
418+
expect(
419+
excludedDeclarations,
420+
excludedDeclarations.length === 0
421+
? ''
422+
: [
423+
'These `.tsx` files declare their own copy of the `__` operation-private-key',
424+
'convention, and the extension boundary above means this pin no longer names',
425+
'them as offenders:',
426+
...excludedDeclarations.map((f) => ` - ${f}`),
427+
'',
428+
'The trade recorded in this file\'s header has expired. ⛔ Do NOT simply widen',
429+
'`isScannedSource` back to `.tsx`: that rebuilds the exact mismatch it was',
430+
'narrowed to remove — a pin judging files CI never re-runs it for. Widening',
431+
'the scanner is only HALF the change; this package\'s declared radius has to',
432+
'widen with it, and that glob is inherited as a watch hint by a second gate',
433+
'whose self-test owns a live `.tsx` specimen. Read the EXTENSION boundary',
434+
'section above, then take it to whoever owns that tool — it is a decision,',
435+
'not a one-line fix.',
436+
].join('\n'),
437+
).toEqual([]);
438+
},
439+
SCAN_TIMEOUT_MS,
440+
);
300441
});

packages/drivers/driver-memory/src/memory-datetime-storage.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
6666
expect(typeof (row as any).created_at, `${(row as any).id} stored form`).toBe('string');
6767
expect((row as any).created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
6868
}
69-
const midnight = raw.find((r: any) => r.id === 'd_midnight');
70-
expect(midnight.created_at).toBe('2026-07-28T00:00:00.000Z');
69+
// `find()` now publishes the contract's `Record<string, unknown>[]`
70+
// (#14435), so `Array.prototype.find` hands back the `undefined` arm it
71+
// has always been able to produce. Narrowed rather than cast: the row
72+
// being present is a real precondition of the assertion below, and while
73+
// `raw` was `any[]` a missing row raised a TypeError instead of failing here.
74+
const midnight = raw.find((r) => r.id === 'd_midnight');
75+
expect(midnight, 'd_midnight seeded and returned by find()').toBeDefined();
76+
expect(midnight!.created_at).toBe('2026-07-28T00:00:00.000Z');
7177
});
7278

7379
it('a date window reaches rows written in BOTH forms', async () => {
@@ -173,7 +179,12 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
173179
}
174180
const all = await driver.find('task', {});
175181
for (const row of all) expect(typeof (row as any).created_on).toBe('string');
176-
expect((all.find((r: any) => r.id === 'on_obj')).created_on).toBe('2026-07-28');
182+
// Same narrowing as above (#14435): the `undefined` arm of
183+
// `Array.prototype.find` is now visible, and the row's presence is an
184+
// assertion in its own right rather than a TypeError waiting to happen.
185+
const onObj = all.find((r) => r.id === 'on_obj');
186+
expect(onObj, 'on_obj seeded and returned by find()').toBeDefined();
187+
expect(onObj!.created_on).toBe('2026-07-28');
177188

178189
const found = await driver.find('task', {
179190
where: { created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } },

0 commit comments

Comments
 (0)