@object-ui/data-objectstack 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,17 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect, beforeEach, vi } from 'vitest';
10
+ import type {
11
+ AnalyticsQuery as SpecAnalyticsQuery,
12
+ AnalyticsResult as SpecAnalyticsResult,
13
+ DatasetCompareTo as SpecDatasetCompareTo,
14
+ DatasetSelection as SpecDatasetSelection,
15
+ } from '@objectstack/spec/contracts';
16
+ import type { PercentScale as SpecPercentScale } from '@objectstack/spec/data';
17
+ // The shared drill-filter builder + its range type (objectui#3813): the date
18
+ // sidecar's only consumer, imported rather than reproduced so the assertions
19
+ // below are about the SAME code the dashboard widget and report renderer run.
20
+ import { buildDatasetDrillFilter, type DatasetDrillRange } from '@object-ui/core';
10
21
  import {
11
22
  ObjectStackAdapter,
12
23
  clearSharedDiscoveryCache,
@@ -130,3 +141,403 @@ describe('ObjectStackAdapter.queryDataset', () => {
130
141
  });
131
142
  });
132
143
  });
144
+
145
+ /* -------------------------------------------------------------------------- */
146
+ /* objectui#3613 — the `selection` parameter IS the spec type, not a copy. */
147
+ /* */
148
+ /* Compile-time pins. A violation is a `tsc --noEmit` error, not a runtime */
149
+ /* failure: this package's tsconfig.json includes its whole `src/**` (tests */
150
+ /* included), so these are checked by `pnpm --filter @object-ui/data-objectstack*/
151
+ /* type-check` with no separate tsconfig.typetests.json — the same property */
152
+ /* spec-symbol-batch6.test.ts documents and relies on. */
153
+ /* -------------------------------------------------------------------------- */
154
+
155
+ type Assert<T extends true> = T;
156
+ type IsAny<T> = 0 extends 1 & T ? true : false;
157
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
158
+ ? true
159
+ : false;
160
+ type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
161
+
162
+ /** The declared type of `queryDataset`'s second parameter, read off the method. */
163
+ type SelectionParam = Parameters<ObjectStackAdapter['queryDataset']>[1];
164
+
165
+ describe('queryDataset(selection) is @objectstack/spec DatasetSelection itself', () => {
166
+ it('is pinned at compile time', () => {
167
+ type _SpecNotAny = Assert<Equal<IsAny<SpecDatasetSelection>, false>>;
168
+
169
+ // THE pin. `Equal` rather than a two-way `extends` on purpose: a
170
+ // restatement that merely *overlaps* the contract satisfies mutual
171
+ // assignability in the drifted direction too (a required `dimension` is
172
+ // still assignable TO an optional one). Structural identity is the only
173
+ // assertion a hand copy cannot pass while drifting.
174
+ type _IsTheSpecType = Assert<Equal<SelectionParam, SpecDatasetSelection>>;
175
+
176
+ expect(true).toBe(true);
177
+ });
178
+
179
+ // The three drifts the deleted copy had accumulated, named individually so a
180
+ // future reader learns what a restatement costs — not just that one existed.
181
+ it('pins compareTo as the spec DatasetCompareTo, with dimension OPTIONAL', () => {
182
+ type CompareTo = NonNullable<SelectionParam['compareTo']>;
183
+ type _IsSpecCompareTo = Assert<Equal<CompareTo, SpecDatasetCompareTo>>;
184
+
185
+ // objectstack#5011 made `dimension` optional BECAUSE the executor resolves
186
+ // it (exactly one dated time dimension → that one; zero or several → a loud
187
+ // error listing the candidates). The copy declared it required, so the
188
+ // compiler demanded from every typed caller precisely the consumer-side
189
+ // dimension guess that change forbids — turning a loud executor error into
190
+ // a silently wrong comparison window.
191
+ type _DimensionOptional = Assert<Equal<CompareTo['dimension'], string | undefined>>;
192
+ type _DimensionWasNotRequired = Assert<Equal<Equal<CompareTo['dimension'], string>, false>>;
193
+ // …and the shape with no `dimension` at all is a legal authoring surface.
194
+ type _KindOnlyIsValid = Assert<
195
+ { kind: 'previousPeriod' } extends CompareTo ? true : false
196
+ >;
197
+
198
+ expect(true).toBe(true);
199
+ });
200
+
201
+ it('pins timeDimensions at the spec shape, not `unknown[]`', () => {
202
+ type _IsSpecTimeDimensions = Assert<
203
+ Equal<SelectionParam['timeDimensions'], SpecAnalyticsQuery['timeDimensions']>
204
+ >;
205
+ type _NotUnknownArray = Assert<
206
+ Equal<Equal<SelectionParam['timeDimensions'], unknown[] | undefined>, false>
207
+ >;
208
+ // The entry shape the executor's compareTo resolution reads (`dateRange`
209
+ // present ⇒ shiftable) — invisible through `unknown[]`.
210
+ type Entry = NonNullable<SelectionParam['timeDimensions']>[number];
211
+ type _EntryHasDimension = Assert<Equal<Entry['dimension'], string>>;
212
+ type _EntryHasDateRange = Assert<HasKey<Entry, 'dateRange'>>;
213
+
214
+ expect(true).toBe(true);
215
+ });
216
+
217
+ it('pins runtimeFilter as a FilterCondition and carries dateGranularity', () => {
218
+ // The copy widened `runtimeFilter` to `Record<string, unknown>`, which
219
+ // erases the `$and`/`$or`/`$not` vocabulary the server parses.
220
+ type _RuntimeFilterNarrowed = Assert<
221
+ Equal<Equal<SelectionParam['runtimeFilter'], Record<string, unknown> | undefined>, false>
222
+ >;
223
+ type _RuntimeFilterKnowsLogicalOps = Assert<
224
+ HasKey<NonNullable<SelectionParam['runtimeFilter']>, '$and'>
225
+ >;
226
+
227
+ // `dateGranularity` (framework#3588) is the drift nobody had noticed: the
228
+ // copy never grew the key, so a typed caller could not bucket a trend by
229
+ // month at all. A restatement does not only drift on the fields it has — it
230
+ // also stops at whatever the contract looked like the day it was written.
231
+ type _HasDateGranularity = Assert<HasKey<SelectionParam, 'dateGranularity'>>;
232
+ type _DateGranularityEnum = Assert<
233
+ Equal<
234
+ SelectionParam['dateGranularity'],
235
+ 'day' | 'week' | 'month' | 'quarter' | 'year' | undefined
236
+ >
237
+ >;
238
+
239
+ expect(true).toBe(true);
240
+ });
241
+
242
+ // The runtime half of the same fact: the adapter must forward `compareTo`
243
+ // VERBATIM. If it ever "helpfully" filled a `dimension` in to satisfy its own
244
+ // old declaration, the executor would stop resolving and every caller's
245
+ // comparison window would depend on the adapter's guess.
246
+ it('forwards a dimension-less compareTo to the server untouched', async () => {
247
+ const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } });
248
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
249
+
250
+ await adapter.queryDataset('sales', {
251
+ dimensions: ['region'],
252
+ measures: ['revenue'],
253
+ timeDimensions: [{ dimension: 'closedAt', granularity: 'month', dateRange: ['2026-01-01', '2026-03-31'] }],
254
+ compareTo: { kind: 'previousPeriod' },
255
+ });
256
+
257
+ const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
258
+ const sent = JSON.parse(post.init.body);
259
+ expect(sent.selection.compareTo).toEqual({ kind: 'previousPeriod' });
260
+ expect(sent.selection.compareTo).not.toHaveProperty('dimension');
261
+ });
262
+ });
263
+
264
+ /* -------------------------------------------------------------------------- */
265
+ /* objectui#3752 — the RESULT's `fields` element is the spec type, not a copy. */
266
+ /* */
267
+ /* Same drift family as #3613 above, on the other side of the call: the return */
268
+ /* type hand-listed five keys and had already stopped at the contract of the */
269
+ /* day it was written — no `percentScale`. Compile-time pins, checked by */
270
+ /* `pnpm --filter @object-ui/data-objectstack type-check` for the reason that */
271
+ /* section documents (this package's tsconfig.json compiles its own tests). */
272
+ /* -------------------------------------------------------------------------- */
273
+
274
+ /** What `queryDataset` RESOLVES to, read off the method. */
275
+ type DatasetEnvelope = Awaited<ReturnType<ObjectStackAdapter['queryDataset']>>;
276
+ /** …and the declared shape of one result column. */
277
+ type ResultField = DatasetEnvelope['fields'][number];
278
+
279
+ describe('queryDataset result fields ARE @objectstack/spec AnalyticsResult fields', () => {
280
+ it('is pinned at compile time', () => {
281
+ type _SpecNotAny = Assert<Equal<IsAny<SpecAnalyticsResult>, false>>;
282
+
283
+ // THE pin: structural identity with the spec's element, so the declaration
284
+ // cannot lag the contract again. `Equal` rather than `extends` for the
285
+ // #3613 reason — a narrower copy stays assignable while drifting.
286
+ type _IsTheSpecField = Assert<Equal<ResultField, SpecAnalyticsResult['fields'][number]>>;
287
+
288
+ // The single key the deleted copy was missing, named so a reader learns
289
+ // WHICH omission cost something rather than merely that one existed.
290
+ type _HasPercentScale = Assert<HasKey<ResultField, 'percentScale'>>;
291
+ type _PercentScaleIsTheSpecUnion = Assert<
292
+ Equal<ResultField['percentScale'], SpecPercentScale | undefined>
293
+ >;
294
+ // …and not a widened `string`: 'fraction' | 'whole' is the whole point —
295
+ // a renderer branches on it instead of guessing from the value (#3136).
296
+ type _PercentScaleIsNotString = Assert<
297
+ Equal<Equal<ResultField['percentScale'], string | undefined>, false>
298
+ >;
299
+
300
+ // The exact shape this replaced, kept as a NEGATIVE pin: restoring the
301
+ // hand-written five keys turns this red, so the sabotage direction is
302
+ // recorded in the test rather than in a commit message.
303
+ type _NotTheFiveKeyCopy = Assert<
304
+ Equal<
305
+ Equal<ResultField, { name: string; type: string; label?: string; format?: string; currency?: string }>,
306
+ false
307
+ >
308
+ >;
309
+
310
+ expect(true).toBe(true);
311
+ });
312
+
313
+ // Why only `fields` is spec-owned and the envelope is not (objectui#3752
314
+ // direction B). The REST route answers `AnalyticsResult` PLUS ADR-0021 D2
315
+ // drill sidecars, but this method rebuilds its own object from the payload
316
+ // and never copies `sql` — so declaring the envelope as `AnalyticsResult &
317
+ // {…}` would advertise a key the adapter structurally cannot return.
318
+ it('pins the envelope as NOT an AnalyticsResult (no `sql`)', () => {
319
+ type _NoSql = Assert<Equal<HasKey<DatasetEnvelope, 'sql'>, false>>;
320
+ // The drill sidecars the spec type does not carry stay locally declared.
321
+ type _HasDrillObject = Assert<HasKey<DatasetEnvelope, 'object'>>;
322
+ type _HasDimensionFields = Assert<HasKey<DatasetEnvelope, 'dimensionFields'>>;
323
+ type _HasDrillRawRows = Assert<HasKey<DatasetEnvelope, 'drillRawRows'>>;
324
+ type _HasDrillRanges = Assert<HasKey<DatasetEnvelope, 'drillRanges'>>;
325
+
326
+ expect(true).toBe(true);
327
+ });
328
+
329
+ // The runtime half: a column's `percentScale` must reach the caller verbatim.
330
+ // The adapter passes `fields` through untouched, so this guards against a
331
+ // future "normalisation" quietly dropping the key the declaration now shows.
332
+ it('returns a percentage column with its percentScale untouched', async () => {
333
+ const { fetchImpl } = makeFetch({
334
+ ok: true,
335
+ body: {
336
+ rows: [{ region: 'NA', winRate: 1 }],
337
+ fields: [
338
+ { name: 'region', type: 'string', label: 'Region' },
339
+ { name: 'winRate', type: 'number', label: 'Win rate', format: '0.0%', percentScale: 'fraction' },
340
+ ],
341
+ },
342
+ });
343
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
344
+
345
+ const result = await adapter.queryDataset(inlineDataset as any, selection);
346
+
347
+ // Read through the DECLARED type, not `as any`: this line is the reason the
348
+ // issue was filed — before the fix it did not compile.
349
+ const winRate = result.fields.find((f) => f.name === 'winRate')!;
350
+ expect(winRate.percentScale).toBe('fraction');
351
+ const region = result.fields.find((f) => f.name === 'region')!;
352
+ expect(region.percentScale).toBeUndefined();
353
+ });
354
+ });
355
+
356
+ /* -------------------------------------------------------------------------- */
357
+ /* objectui#3813 — the date-range drill sidecar reaches the caller. */
358
+ /* */
359
+ /* Third instance of the same family as #3613/#3752, this time on the RUNTIME */
360
+ /* face: `queryDataset` rebuilds its result by hand-picking keys off the */
361
+ /* payload, and `drillRanges` was never in the list — so the server's #1752 */
362
+ /* date-bucket drill scope was silently dropped by the only real adapter, and */
363
+ /* a date-only dashboard/report lost its entire drill entry point (`canDrill` */
364
+ /* can only be true via these ranges when no equality drill dim exists). */
365
+ /* */
366
+ /* The envelopes below are the SHAPES THE SERVER ACTUALLY SENDS, not idealised */
367
+ /* ones — that distinction is why the existing consumer tests could not see the */
368
+ /* bug (they mock a data source and feed `drillRanges` in directly): */
369
+ /* - `POST /analytics/dataset/query` answers `res.json(result)` — a BARE */
370
+ /* result, no `{ success, data }` wrapper (objectstack `rest-server.ts`); */
371
+ /* - it carries `sql`, which this adapter deliberately does not return; */
372
+ /* - for a DATE-ONLY grouping the server sets `object` + `drillRanges` and */
373
+ /* NOTHING else drill-related: `service-analytics` excludes date dims from */
374
+ /* `drillDims`, so `dimensionFields` / `drillRawRows` are absent entirely. */
375
+ /* -------------------------------------------------------------------------- */
376
+
377
+ /** Sales bucketed by month only — no non-date dimension, so no equality drill. */
378
+ const monthlyDataset = {
379
+ name: 'sales_by_month', label: 'Sales by month', object: 'opportunity',
380
+ dimensions: [{ name: 'closed_month', field: 'close_date', type: 'date', dateGranularity: 'month' }],
381
+ measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
382
+ };
383
+ const monthlySelection = { dimensions: ['closed_month'], measures: ['revenue'] };
384
+
385
+ /**
386
+ * `DatasetWidget`'s drill predicate, reproduced from the widget rather than
387
+ * restated: `drillDims` is `dimensions.filter((d) => d in dimensionFields)` and
388
+ * `canDrill = !!drillObject && (drillDims.length > 0 || !!drillRanges?.length)`
389
+ * (`packages/plugin-dashboard/src/DatasetWidget.tsx:590-593`; the report
390
+ * renderer's per-row `hasRange` gate at `DatasetReportRenderer.tsx:431` and its
391
+ * pivot twin at `:855` are the same fact per row). Fed straight from the
392
+ * adapter's return so the assertion is about what the ADAPTER produced.
393
+ */
394
+ function consumerDrillView(
395
+ result: Awaited<ReturnType<ObjectStackAdapter['queryDataset']>>,
396
+ dimensions: string[],
397
+ ) {
398
+ const drillDims = result.dimensionFields ? dimensions.filter((d) => d in result.dimensionFields!) : [];
399
+ return {
400
+ drillDims,
401
+ canDrill: !!result.object && (drillDims.length > 0 || !!result.drillRanges?.length),
402
+ };
403
+ }
404
+
405
+ describe('queryDataset passes the server drillRanges sidecar through (#1752)', () => {
406
+ beforeEach(() => clearSharedDiscoveryCache());
407
+
408
+ it('keeps drillRanges verbatim for a DATE-ONLY grouping, so canDrill is true', async () => {
409
+ const { fetchImpl } = makeFetch({
410
+ ok: true,
411
+ // The real envelope: bare (no success/data wrapper), with `sql`, with
412
+ // `object` re-set by the range block, and WITHOUT dimensionFields /
413
+ // drillRawRows — the server emits none of those for a date-only grouping.
414
+ body: {
415
+ rows: [
416
+ { closed_month: '2026-05', revenue: 100 },
417
+ { closed_month: '2026-06', revenue: 250 },
418
+ ],
419
+ fields: [
420
+ { name: 'closed_month', type: 'date', label: 'Closed month' },
421
+ { name: 'revenue', type: 'number', label: 'Revenue', format: '$0,0' },
422
+ ],
423
+ sql: 'SELECT date_trunc(...) AS closed_month, SUM(amount) AS revenue FROM opportunity GROUP BY 1',
424
+ object: 'opportunity',
425
+ drillRanges: [
426
+ { closed_month: { field: 'close_date', gte: '2026-05-01', lt: '2026-06-01' } },
427
+ { closed_month: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } },
428
+ ],
429
+ },
430
+ });
431
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
432
+
433
+ const result = await adapter.queryDataset(monthlyDataset as any, monthlySelection);
434
+
435
+ // (1) The key arrives at the caller, untouched and row-aligned. Read through
436
+ // the DECLARED type, not `as any` — before the fix this line did not compile.
437
+ expect(result.drillRanges).toEqual([
438
+ { closed_month: { field: 'close_date', gte: '2026-05-01', lt: '2026-06-01' } },
439
+ { closed_month: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } },
440
+ ]);
441
+ expect(result.drillRanges).toHaveLength(result.rows.length);
442
+
443
+ // (2) The mock really is the server's date-only shape, not a convenient one:
444
+ // if these ever become defined, the fixture stopped exercising the case
445
+ // where `drillRanges` is the ONLY thing that can make a widget drillable.
446
+ expect(result.dimensionFields).toBeUndefined();
447
+ expect(result.drillRawRows).toBeUndefined();
448
+
449
+ // (3) The issue's acceptance anchor, evaluated with the consumers' own
450
+ // predicate: a date-only chart is drillable again.
451
+ const { drillDims, canDrill } = consumerDrillView(result, monthlySelection.dimensions);
452
+ expect(drillDims).toEqual([]);
453
+ expect(canDrill).toBe(true);
454
+
455
+ // (4) …and the drilled filter really scopes to the CLICKED bucket, built by
456
+ // the shared `buildDatasetDrillFilter` the widget and the report both call.
457
+ expect(buildDatasetDrillFilter(result.drillRawRows?.[1], drillDims, result.dimensionFields ?? {}, undefined, result.drillRanges?.[1]))
458
+ .toEqual({ close_date: { $gte: '2026-06-01', $lt: '2026-07-01' } });
459
+ });
460
+
461
+ it('keeps BOTH sidecars for a mixed date + non-date grouping (no superset drill)', async () => {
462
+ const { fetchImpl } = makeFetch({
463
+ ok: true,
464
+ body: {
465
+ rows: [{ region: 'North America', closed_month: '2026-06', revenue: 250 }],
466
+ fields: [
467
+ { name: 'region', type: 'string', label: 'Region' },
468
+ { name: 'closed_month', type: 'date', label: 'Closed month' },
469
+ { name: 'revenue', type: 'number', label: 'Revenue' },
470
+ ],
471
+ object: 'opportunity',
472
+ // Equality sidecars cover the non-date dim only (and carry the RAW value
473
+ // `NA`, not the row's resolved label "North America"); the date dim gets
474
+ // a range instead.
475
+ dimensionFields: { region: 'account.region' },
476
+ drillRawRows: [{ region: 'NA' }],
477
+ drillRanges: [{ closed_month: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } }],
478
+ },
479
+ });
480
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
481
+
482
+ const result = await adapter.queryDataset(monthlyDataset as any, { dimensions: ['region', 'closed_month'], measures: ['revenue'] });
483
+
484
+ const { drillDims, canDrill } = consumerDrillView(result, ['region', 'closed_month']);
485
+ expect(canDrill).toBe(true);
486
+ expect(drillDims).toEqual(['region']);
487
+
488
+ // Both halves in one filter. Without the range the drill was a SUPERSET —
489
+ // clicking June's bar opened every month for that region.
490
+ expect(buildDatasetDrillFilter(result.drillRawRows?.[0], drillDims, result.dimensionFields ?? {}, { stage: 'won' }, result.drillRanges?.[0]))
491
+ .toEqual({
492
+ stage: 'won',
493
+ 'account.region': 'NA',
494
+ close_date: { $gte: '2026-06-01', $lt: '2026-07-01' },
495
+ });
496
+ });
497
+
498
+ it('leaves drillRanges undefined when the server sends none (non-date grouping)', async () => {
499
+ const { fetchImpl } = makeFetch({
500
+ ok: true,
501
+ body: {
502
+ rows: [{ region: 'NA', revenue: 100 }],
503
+ fields: [{ name: 'revenue', type: 'number' }],
504
+ object: 'opportunity',
505
+ dimensionFields: { region: 'account.region' },
506
+ drillRawRows: [{ region: 'NA' }],
507
+ },
508
+ });
509
+ const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
510
+
511
+ const result = await adapter.queryDataset(inlineDataset as any, selection);
512
+
513
+ // Absent stays absent — the passthrough must not invent an empty array,
514
+ // which would flip `canDrill`'s `!!drillRanges?.length` reasoning nowhere
515
+ // but would make the two sidecars' index alignment meaningless.
516
+ expect(result.drillRanges).toBeUndefined();
517
+ expect(consumerDrillView(result, ['region']).canDrill).toBe(true);
518
+ });
519
+
520
+ it('is pinned at compile time as the SHARED DatasetDrillRange, not a restatement', () => {
521
+ type DrillRanges = NonNullable<DatasetEnvelope['drillRanges']>;
522
+ type RangeEntry = DrillRanges[number][string];
523
+
524
+ // THE pin. `@object-ui/core`'s `DatasetDrillRange` is the single in-repo
525
+ // declaration of this shape — it is what `buildDatasetDrillFilter` accepts
526
+ // and what `DatasetWidget` / `DatasetReportRenderer` type their state with.
527
+ // Nothing in `@objectstack/spec` owns it yet (the producer's own
528
+ // `AnalyticsResultWithDrill` is local to `service-analytics`), so identity
529
+ // with the shared interface is the strongest available contract pin: a local
530
+ // `{ field: string; gte: string; lt: string }` copy — the shape a reader is
531
+ // most tempted to write here — fails this line, per #3613's lesson that a
532
+ // restatement stays assignable while it drifts.
533
+ type _IsTheSharedType = Assert<Equal<RangeEntry, DatasetDrillRange>>;
534
+ type _SharedTypeNotAny = Assert<Equal<IsAny<DatasetDrillRange>, false>>;
535
+
536
+ // Row-aligned array of dimension NAME → range, mirroring `drillRawRows`.
537
+ type _IsRowAlignedArray = Assert<Equal<DrillRanges, Array<Record<string, DatasetDrillRange>>>>;
538
+ // Optional, because the server omits it whenever no date dim was bucketed.
539
+ type _IsOptional = Assert<Equal<DatasetEnvelope['drillRanges'], DrillRanges | undefined>>;
540
+
541
+ expect(true).toBe(true);
542
+ });
543
+ });
@@ -205,7 +205,27 @@ describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => {
205
205
  // reason had appeared. `Equal` is used rather than `extends` because
206
206
  // `'readonly' | 'readonly_when' extends string` is true in the drifted
207
207
  // direction too.
208
- type _ReasonIsTheEnum = Assert<Equal<DroppedFieldsEvent['reason'], 'readonly' | 'readonly_when'>>;
208
+ //
209
+ // THIS PIN FIRED, and its firing is the whole point (objectui#4167).
210
+ // `@objectstack/spec` 17.0.0-rc.6 added a third arm, `primary_key`, and this
211
+ // assertion is what reported it — a `tsc` error naming the exact widening,
212
+ // on a bump whose build and full vitest run were both green. Under the
213
+ // deleted hand copy's bare `string` the arm would have arrived silently, and
214
+ // `writeWarningToast` would have narrated it as "Read-only, so it did not
215
+ // take effect" with nobody the wiser.
216
+ //
217
+ // The member list is updated here; the CONSUMER gap is objectui#3935, which
218
+ // was filed BEFORE this arm shipped and named this exact moment as its
219
+ // activation condition ("the moment `.objectui-sha`'s spec pin moves past
220
+ // that change, a user who triggers the primary-key strip is told the field
221
+ // is read-only"). rc.6 is that moment, so #3935 stops being a prediction.
222
+ // Not fixed here: `emitWriteWarning`'s binary ternary needs replacing with
223
+ // an exhaustive map plus a third wording in both locale tables, which is a
224
+ // UX and i18n read of its own rather than bump adaptation. #3935 owns it and
225
+ // already prescribes the shape.
226
+ type _ReasonIsTheEnum = Assert<
227
+ Equal<DroppedFieldsEvent['reason'], 'readonly' | 'readonly_when' | 'primary_key'>
228
+ >;
209
229
  type _ReasonIsNotString = Assert<Equal<Equal<DroppedFieldsEvent['reason'], string>, false>>;
210
230
 
211
231
  expect(true).toBe(true);