-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathprotocol.ts
More file actions
9211 lines (8849 loc) · 463 KB
/
Copy pathprotocol.ts
File metadata and controls
9211 lines (8849 loc) · 463 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type {
DataProtocol, MetadataProtocol, PackageProtocol,
} from '@objectstack/spec/api';
import { IDataEngine } from '@objectstack/core';
import { readEnvWithDeprecation } from '@objectstack/types';
import type { MetadataHostEngine } from './host-engine.js';
import { SysMetadataRepository, type SysMetadataEngine } from './sys-metadata-repository.js';
import { ConflictError, assertProtocolCompat, type MetadataItem } from '@objectstack/metadata-core';
import type {
BatchUpdateRequest,
BatchUpdateResponse,
UpdateManyDataRequest,
DeleteManyDataRequest,
InstallPackageRequest,
InstallPackageResponse
} from '@objectstack/spec/api';
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
import { readServiceSelfInfo } from '@objectstack/spec/api';
import {
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS,
RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots,
type QueryAliasConflict, type QueryAliasSlot,
type DroppedFieldsEvent, type QueryAST,
} from '@objectstack/spec/data';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec';
import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui';
import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system';
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
import {
extractProtection,
evaluateLockForWrite,
evaluateLockForDelete,
resolveLockState,
type MetadataLock,
type MetadataProvenance,
} from '@objectstack/spec/kernel';
import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel';
import { stripReadDecorations } from '@objectstack/spec/kernel';
import { z } from 'zod';
import {
computeMetadataDiagnostics,
computeViewReferenceDiagnostics,
decorateMetadataItem,
decorateMetadataItems,
type MetadataDiagnostics,
} from './metadata-diagnostics.js';
import type {
StoredFlowCanonicalization,
StoredMigrationNotice,
StoredMigrationReport,
StoredMigrationRow,
} from './stored-migration.js';
/**
* Canonical Zod schema per metadata type lives in
* `@objectstack/spec/kernel/metadata-type-schemas` and is exposed through
* {@link getMetadataTypeSchema}. Both save-time validation
* ({@link resolveOverlaySchema}) and the `/meta/types/:type` JSON Schema
* emitter consult that single source of truth, so adding a new
* metadata-type schema requires editing exactly one file (or calling
* `registerMetadataTypeSchema()` from a plugin).
*/
// (TYPE_TO_SCHEMA removed — use `getMetadataTypeSchema(type)` directly.)
/**
* Canonical {@link FormView} layout per metadata type. Sourced from the
* shared {@link METADATA_FORM_REGISTRY} in `@objectstack/spec/system` so
* the runtime form payload, the i18n extractor, and Studio all read from
* a single source of truth.
*
* Types without an entry render with the auto-generated single-section
* layout derived from their JSON Schema (acceptable for simple types).
*/
const TYPE_TO_FORM: Readonly<Record<string, FormView>> = METADATA_FORM_REGISTRY;
/**
* The ONE canonical spelling of a metadata type at the `/meta` read/write/delete
* boundary (#4432).
*
* Prime Directive #3 already fixes the answer — metadata type names are
* SINGULAR (`'action'`, `'view'`), REST paths are plural (`/meta/actions`) — and
* #3985 taught the per-type gates to accept both spellings. What it did not do
* is fold them, so the two spellings addressed two different namespaces and the
* layers below disagreed about which one an item lived in:
*
* - the `SysMetadataRepository` write/delete path already folded to singular,
* while the authorization tier above it (`isOverlayAllowed`,
* `isArtifactBacked`) and the registry heal below it
* (`restoreArtifactRegistryView`) read the caller's spelling;
* - `getMetaItems` registered overlay rows back into the SchemaRegistry under
* the caller's spelling. One plural-spelled read minted a plural registry
* entry, `listItems('actions')` stopped being empty, and the singular
* fallback that had been supplying the code-authored items never ran again —
* so one overlay row shadowed an entire code-authored listing, and survived
* the DELETE that was supposed to lift it.
*
* Folding at the boundary (rather than adding another spelling-tolerant lookup
* one layer down) is Prime Directive #12 applied to a type key: one contract,
* not N dialects. Reads of data AT REST still try the other spelling as a
* fallback — rows written under a plural `type` before this fix are real, and
* nothing rewrites them on upgrade.
*/
function canonicalMetaType(type: string): string {
return PLURAL_TO_SINGULAR[type] ?? type;
}
/** {@link canonicalMetaType} applied to a `{ type }` request, without mutating the caller's object. */
function canonicalizeMetaRequestType<T extends { type: string }>(request: T): T {
const type = canonicalMetaType(request.type);
return type === request.type ? request : { ...request, type };
}
/**
* [#3770] One-shot flag for the "engine has no schema registry" warning emitted
* by {@link ObjectStackProtocolImplementation.assertObjectRegistered}. The
* condition is a property of how the host constructed the engine, so it is
* constant for the process — warn once, not once per request.
*/
let warnedNoRegistryForDataGate = false;
/**
* Convert a Zod schema to a JSON Schema, returning `undefined` if conversion
* fails (e.g. unsupported constructs). Cached per schema reference.
*/
const _jsonSchemaCache = new WeakMap<z.ZodTypeAny, Record<string, unknown> | null>();
function toJsonSchemaSafe(schema: z.ZodTypeAny): Record<string, unknown> | undefined {
const cached = _jsonSchemaCache.get(schema);
if (cached !== undefined) return cached ?? undefined;
try {
const result = z.toJSONSchema(schema, { unrepresentable: 'any' }) as Record<string, unknown>;
_jsonSchemaCache.set(schema, result);
return result;
} catch {
_jsonSchemaCache.set(schema, null);
return undefined;
}
}
/**
* Hand-crafted fallback JSON Schemas for metadata types whose Zod schema
* cannot be safely converted via `z.toJSONSchema()` (e.g. due to recursive
* references or non-representable constructs like `z.lazy()` chains).
*
* These mirror the shape consumed by the corresponding `*.form.ts` layouts,
* so the SchemaForm renderer can still produce a real form (instead of
* falling back to the raw JSON editor). All fields use lenient types
* (`string | object | array`) because the widget hint in the form layout
* is what actually drives the UI control selection — the JSON Schema is
* only used to (a) seed defaults and (b) report which property names exist.
*/
const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
object: {
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
pluralLabel: { type: 'string' },
icon: { type: 'string' },
description: { type: 'string' },
isSystem: { type: 'boolean', default: false },
datasource: { type: 'string' },
fields: {
// Canonical Object.fields is a name-keyed map
// (Record<string, FieldDefinition>) — insertion order is
// display order. The SchemaForm engine recognises
// `additionalProperties` as a Record and dispatches to
// the `record` form-field renderer (ADR-0007). The form
// layout in `object.form.ts` declares `type: 'record'`
// so the inner `additionalProperties` schema is used to
// shape each value.
type: 'object',
default: {},
additionalProperties: {
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
type: { type: 'string' },
required: { type: 'boolean', default: false },
unique: { type: 'boolean', default: false },
defaultValue: {},
description: { type: 'string' },
},
required: ['type'],
},
},
capabilities: { type: 'object', additionalProperties: true },
},
required: ['name'],
additionalProperties: true,
},
action: {
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
objectName: { type: 'string' },
icon: { type: 'string' },
type: { type: 'string', enum: ['url', 'flow', 'api', 'script'] },
variant: { type: 'string', enum: ['primary', 'secondary', 'danger', 'ghost', 'outline'] },
target: { type: 'string' },
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
body: {
type: 'array',
default: [],
items: {
type: 'object',
properties: {
line: { type: 'string' },
},
},
},
params: {
type: 'array',
default: [],
items: {
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
type: { type: 'string' },
required: { type: 'boolean', default: false },
},
required: ['name'],
},
},
confirmText: { type: 'string' },
successMessage: { type: 'string' },
refreshAfter: { type: 'boolean', default: true },
locations: {
type: 'array',
default: [],
items: {
type: 'object',
properties: {
location: { type: 'string' },
},
},
},
component: { type: 'string' },
visible: { type: 'string' },
disabled: { type: 'string' },
// No `shortcut` / `bulkEnabled`: spec 17 retired both as
// `retiredKey()` tombstones, so authoring either is a hard parse
// rejection. This schema is what the Studio designer renders its
// fallback form from, so leaving them here handed authors two
// inputs that could only ever produce an unsaveable draft
// (objectui#3145 removed the matching dedicated controls).
// `bulkEnabled`'s replacement is the list view's `bulkActions` /
// `bulkActionDefs`; `shortcut` has none.
aiExposed: { type: 'boolean', default: false },
recordIdParam: { type: 'string' },
recordIdField: { type: 'string' },
bodyShape: { type: 'string', enum: ['flat', 'nested'] },
},
required: ['name', 'label', 'type'],
additionalProperties: true,
},
// ADR-0088 (#4509): the `validation` kind is retired, so its hand-crafted
// form goes with it. Rules are authored inside `object.validations[]` and
// edited on the object; there is no standalone validation editor to render
// a schema for. (The form was flat by necessity — ValidationRuleSchema is a
// 6-variant discriminated union the generic SchemaForm treats as opaque —
// and it had no field for the object being validated, which is precisely
// the gap that retired the kind: a rule saved here bound to nothing.)
};
/**
* Zod schemas used to validate overlay items before they are persisted into
* `sys_metadata` by {@link ObjectStackProtocolImplementation.saveMetaItem}.
*
* Single source of truth: the spec-side {@link getMetadataTypeSchema}
* registry (`@objectstack/spec/kernel/metadata-type-schemas`). Every
* metadata type whose payload should round-trip through Studio's
* generic editor maps to its canonical Zod schema there; this function
* is a plural→singular adapter on top of it.
*
* Validation policy:
* - `safeParse` is used so we can craft a 422 with structured `issues`.
* - We do NOT replace the persisted document with `parsed.data`; the
* original payload is stored verbatim so Studio-only auxiliary fields
* (e.g. `isPinned`, `isDefault`, `sortOrder`) survive the round-trip.
* The one exception is filter `operator` spellings, which are grafted back
* from `parsed.data` so a save stops minting new legacy-alias rows — see
* {@link graftNormalizedOperators}.
* - Types without a registered schema (the wiring-layer types
* `function`/`service`/`router`, and any plugin types that have not
* yet called `registerMetadataTypeSchema()`) fall through unvalidated.
*/
function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
return getMetadataTypeSchema(singular) ?? null;
}
/**
* [#4435] The 404 a single-record operation answers when the id names no row.
*
* Extracted so the READ and the two WRITE paths cannot disagree about it. They
* did: `getData` answered `404 RECORD_NOT_FOUND` while `updateData` returned
* `200 { record: null }` and `deleteData` returned `200 { success: true }` for
* any string in the path — so a typo'd id, an already-deleted row and a real
* deletion were indistinguishable, and a client PATCHing a concurrently deleted
* record was told its write had landed.
*
* That is the same silent-no-op shape the v17 train removed everywhere else
* this window (#4240/#4303/#4315 refuse missing fields, #4169 refuses unknown
* params, #4190 stopped dropping filters) — a write that touched zero rows
* reporting 200 is that shape one level up, on the verb where it costs the
* most.
*/
function recordNotFoundError(object: string, id: string | number): Error {
const err = new Error(`Record ${id} not found in ${object}`) as Error & {
code?: string;
status?: number;
object?: string;
};
err.code = 'RECORD_NOT_FOUND';
err.status = 404;
err.object = object;
return err;
}
/**
* A 400 for a `$filter` ARRAY that looks like a filter AST but is not one.
*
* The message has to be *actionable from the request*, which is the whole point
* of rejecting here rather than letting a driver fail later: the caller sent a
* query parameter, so the error names the offending element and the vocabulary
* it was checked against — not a driver-internal builder state.
*
* Diagnoses the three shapes `isFilterAST` refuses, in the order they occur in
* practice. #4121. Sibling of {@link unusableFilterError}, which covers the
* non-array ways a filter fails to become one (#4181); both emit
* `INVALID_FILTER` so the condition has one wire code however it was reached.
*/
function malformedFilterArrayError(filter: unknown[]): Error {
const detail = describeMalformedFilter(filter);
const err: any = new Error(
`Malformed $filter: ${detail} A filter array is a comparison ` +
`[field, operator, value], a logical node ["and"|"or", ...conditions], or a ` +
`list of those. Recognised operators: ${[...VALID_AST_OPERATORS].sort().join(', ')}.`,
);
err.status = 400;
err.code = 'INVALID_FILTER';
return err;
}
/** The specific reason a filter array failed `isFilterAST`, for the message above. */
function describeMalformedFilter(filter: unknown[]): string {
const [first, second] = filter;
const isKeyword = typeof first === 'string' && ['and', 'or'].includes(first.toLowerCase());
// `["and"]` / `["or"]` with nothing to join. The one shape that still
// returned every row silently after #3948: the driver sets its join mode,
// matches no element, and emits no predicate.
if (isKeyword && filter.length < 2) {
return `logical node ["${String(first)}"] has no conditions to join.`;
}
// A bare triple whose operator is outside the AST vocabulary — the original
// `before` / `after` / `'not in'` case.
if (typeof first === 'string' && !isKeyword && typeof second === 'string'
&& !VALID_AST_OPERATORS.has(second.toLowerCase())) {
return `unrecognised operator "${second}" in [${JSON.stringify(first)}, ...].`;
}
// An element that is neither a join keyword nor a nested condition.
const badIndex = filter.findIndex(
(item) => !Array.isArray(item)
&& !(typeof item === 'string' && ['and', 'or'].includes(item.toLowerCase())),
);
if (badIndex >= 0 && filter.some((item) => Array.isArray(item))) {
const bad = filter[badIndex];
return `element ${badIndex} is ${bad === null ? 'null' : typeof bad}, ` +
`expected a condition array or a logical keyword.`;
}
return `${JSON.stringify(filter)} is not a recognised filter shape.`;
}
/**
* The keys THIS file stamps onto every served document (`_diagnostics` via
* `decorateMetadataItem`, `_draft` via the draft-preview overlay) — and the
* strip that keeps them out of a persisted body (#4326) or a strict re-parse
* (cloud#971).
*
* The list itself lives in `@objectstack/spec` because this module PRODUCES the
* decoration while consumers in other layers (`service-automation`'s cold-boot
* flow bind, …) have to REMOVE it: one shared definition is what stops the two
* sides from drifting. See `spec/kernel/metadata-read-decorations.ts` for the
* full rationale and for why the ADR-0010 protection envelope (`_lock`,
* `_packageId`, …) is deliberately not stripped despite the shared spelling.
*
* Re-exported here so `@objectstack/metadata-protocol`'s public surface is
* unchanged.
*/
export { stripReadDecorations };
/**
* Guarantee a `view` body carries a top-level `name`.
*
* {@link ObjectStackProtocolImplementation.getMetaItems} only surfaces a
* sys_metadata overlay row when its parsed body has a top-level `name` (objects
* and dashboards include one; some view producers — notably loose `{ list }`
* fragments — do not, so the view is silently dropped from the object's view
* list and never appears as a tab). We stamp the save name here, at the single
* write chokepoint, without otherwise reshaping the document.
*
* Deliberately does NOT convert shape: both the `defineView` container form
* (`{ list, listViews, … }`) and the `{ name, object, viewKind, config }`
* record form are valid and the console consumes both — reshaping a container
* into a record risks producing an invalid record (e.g. a non-`<object>.<key>`
* name). Structural validity is enforced separately by the view metadata schema
* during the spec-validation step. No-op for non-view types and bodies that
* already carry a `name`.
*
* When `baseline` is provided (the registry entry this overlay will shadow),
* missing identity fields — `viewKind`, `object`, `label` — are inherited onto
* non-container bodies. A runtime personalization PUT (console column sort,
* inline edit, …) sends only the raw view config; persisting it verbatim makes
* the overlay replace the flattened package entry minus its identity, and the
* view silently drops out of every consumer that filters on
* `viewKind`/`object` (e.g. the switcher endpoint). See #2555. Container
* bodies are left untouched — `expandViewContainer` derives identity itself.
*/
export function normalizeViewMetadata(type: string, item: unknown, saveName: string, baseline?: unknown): unknown {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
if (singular !== 'view') return item;
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
const it = item as Record<string, unknown>;
const patch = viewIdentityPatch(it, baseline);
if (it.name && !patch) return it;
return { ...it, ...(it.name ? undefined : { name: saveName }), ...patch };
}
/**
* Persist the operator spellings the spec's own schema normalized, and nothing
* else. objectui#2945.
*
* `ViewFilterRuleSchema.operator` is `z.preprocess(normalizeFilterOperator, …)`,
* so a stored `notEquals` / `gt` / `isNull` is folded to its canonical form
* during validation — and then the result was thrown away, because `saveMeta`
* persists the authored body verbatim (deliberately: `parsed.data` strips the
* Studio-only auxiliary fields that ride along with an overlay). The alias table
* `VIEW_FILTER_OPERATOR_ALIASES` therefore keeps acquiring *new* rows with every
* save, which is why it can never be retired: there is no point at which the
* last alias row is behind you.
*
* This grafts the normalization back on without giving up the verbatim body.
* It walks the authored value and the parsed value in lockstep **by structure**
* and copies across exactly one thing: an `operator` whose parsed value differs
* from the authored one. No key list to maintain — every filter site the schema
* knows about is covered, including ones added later — and nothing is added,
* removed, reordered or defaulted, so an auxiliary field cannot be lost the way
* a wholesale `parsed.data` swap would lose it.
*
* Returns the input itself when nothing changed, so the common case allocates
* nothing.
*/
export function graftNormalizedOperators(authored: unknown, parsed: unknown): unknown {
if (Array.isArray(authored)) {
if (!Array.isArray(parsed)) return authored;
let changed = false;
const out = authored.map((entry, i) => {
const next = graftNormalizedOperators(entry, parsed[i]);
if (next !== entry) changed = true;
return next;
});
return changed ? out : authored;
}
if (!authored || typeof authored !== 'object') return authored;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return authored;
const a = authored as Record<string, unknown>;
const p = parsed as Record<string, unknown>;
let patch: Record<string, unknown> | undefined;
for (const [key, value] of Object.entries(a)) {
// The one value this function is allowed to rewrite. Guarded on both
// sides being strings so a `$`-token condition object — a different
// operator vocabulary entirely — cannot be reshaped by accident.
if (key === 'operator' && typeof value === 'string' && typeof p[key] === 'string') {
if (p[key] !== value) (patch ??= {})[key] = p[key];
continue;
}
const next = graftNormalizedOperators(value, p[key]);
if (next !== value) (patch ??= {})[key] = next;
}
return patch ? { ...a, ...patch } : authored;
}
/**
* #2555 — compute the identity fields (`viewKind`, `object`, `label`) a view
* overlay is missing but the registry entry it shadows carries. The overlay's
* own fields always win. Returns `null` (nothing to inherit) for `defineView`
* container bodies — their identity is derived at expansion — and for
* absent/invalid baselines.
*/
function viewIdentityPatch(overlay: Record<string, unknown>, baseline: unknown): Record<string, unknown> | null {
if (!baseline || typeof baseline !== 'object' || Array.isArray(baseline)) return null;
if ('list' in overlay || 'listViews' in overlay || 'formViews' in overlay) return null;
const b = baseline as Record<string, unknown>;
const patch: Record<string, unknown> = {};
for (const key of ['viewKind', 'object', 'label'] as const) {
if (overlay[key] === undefined && b[key] !== undefined) patch[key] = b[key];
}
return Object.keys(patch).length > 0 ? patch : null;
}
/**
* ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope
* onto a returned item so artifact-level lock/packageId/provenance
* always wins over whatever was persisted in the `sys_metadata` overlay
* row. Returns `item` unchanged when no artifact baseline is available.
*
* The artifact's `_lock`, `_lockReason`, `_packageId`, `_packageVersion`,
* and `_provenance` are the source of truth — an overlay copy may
* pre-date the artifact's protection declaration and would otherwise
* mask it.
*/
function mergeArtifactProtection(item: unknown, artifactItem: unknown): unknown {
if (item === undefined || item === null) return item;
if (artifactItem === undefined || artifactItem === null) return item;
const a = artifactItem as Record<string, unknown>;
if (typeof a !== 'object') return item;
const out: Record<string, unknown> = { ...(item as Record<string, unknown>) };
if (a._lock !== undefined) out._lock = a._lock;
if (a._lockReason !== undefined) out._lockReason = a._lockReason;
if (a._lockDocsUrl !== undefined) out._lockDocsUrl = a._lockDocsUrl;
if (a._lockSource !== undefined) out._lockSource = a._lockSource;
if (a._packageId !== undefined) out._packageId = a._packageId;
if (a._packageVersion !== undefined) out._packageVersion = a._packageVersion;
if (a._provenance !== undefined) out._provenance = a._provenance;
return out;
}
/**
* ADR-0048 (#1828) — composite dedup identity for the unscoped metadata list.
*
* Two installed packages may legitimately ship the same `type`/`name`
* (e.g. `page/home`); the SchemaRegistry already stores them under distinct
* `${packageId}:${name}` keys. Any list-merge that deduplicates by bare `name`
* collapses the two packages' rows into one (last-write-wins), which is the
* bug this key closes. A `NUL` separator keeps names containing `:` unambiguous.
*/
function metaItemKey(packageId: string | null | undefined, name: unknown): string {
return `${packageId ?? ''}\u0000${String(name)}`;
}
/**
* ADR-0048 (#1828) — package-aware overlay merge for the unscoped metadata list.
*
* `baseItems` (the lower layer: registry artifacts, or the running result) and
* `records` (the higher layer: active `sys_metadata` overlays, or draft rows)
* are merged so that:
*
* • Two installed packages shipping the same `type/name` stay TWO rows —
* resolution is per `(package, name)`, not bare `name`, so a higher-layer
* row no longer collapses a same-name row from a different package.
* • For each package `P` that owns a row of a given name, the winner is the
* LATEST contribution that is either `P`'s own row or a package-less
* ("global", `package_id IS NULL`) row — mirroring
* `getMetaItem(name, packageId=P)`'s "scoped-then-global-fallback"
* resolution, so the list and single-item paths agree. This is also why a
* legacy row whose active/draft layers disagree on package attribution
* still collapses (a package-less active row + its `package_id`-bearing
* draft resolve to the one package slot, draft winning).
* • A name with NO package-owned row resolves to its latest package-less
* contribution — the pre-existing env-wide behaviour, unchanged.
*
* `transform(data, prev)` runs on each `records` body before it enters the
* merge (view-identity healing, draft tagging); `prev` is the base row it
* shadows at the same slot (or any same-name base row), else undefined.
*/
function mergePackageAwareOverlay(
baseItems: unknown[],
records: Array<{ data: unknown; packageId: string | undefined }>,
transform?: (data: any, prev: any) => any,
): unknown[] {
// Per-name, layer-ordered contributions; `pkg: undefined` = package-less.
const buckets = new Map<string, Array<{ pkg: string | undefined; item: any }>>();
const order: string[] = []; // first-seen name order → stable output
const push = (name: string, pkg: string | undefined, item: any) => {
let list = buckets.get(name);
if (!list) { buckets.set(name, (list = [])); order.push(name); }
list.push({ pkg, item });
};
for (const raw of baseItems) {
const item = raw as any;
if (item && typeof item === 'object' && 'name' in item) {
push(item.name, (item._packageId ?? undefined) as string | undefined, item);
}
}
for (const { data, packageId } of records) {
const body = data as any;
if (!(body && typeof body === 'object' && 'name' in body)) continue;
// The base row this record shadows at its own slot (for view-identity
// healing): a same-package row, else a package-less one, else any
// same-name row it stands in for.
const list = buckets.get(body.name);
const prev = list
? (list.find((c) => c.pkg === packageId)?.item
?? list.find((c) => c.pkg === undefined)?.item
?? list[0]?.item)
: undefined;
push(body.name, packageId, transform ? transform(body, prev) : body);
}
const out: unknown[] = [];
for (const name of order) {
const list = buckets.get(name)!;
const reals = Array.from(new Set(list.filter((c) => c.pkg !== undefined).map((c) => c.pkg)));
if (reals.length === 0) {
out.push(list[list.length - 1].item); // latest package-less row wins
continue;
}
for (const real of reals) {
// getMetaItem(name, real) resolution: latest row that is `real`'s
// own or package-less (global fallback).
let chosen: any;
for (const c of list) {
if (c.pkg === real || c.pkg === undefined) chosen = c.item;
}
if (chosen === undefined) continue;
// A package-less body standing in for package `real` must carry
// `real`'s provenance (the base row it replaced was `real`'s).
if (chosen._packageId === undefined) chosen = { ...chosen, _packageId: real };
out.push(chosen);
}
}
return out;
}
/**
* Simple hash function for ETag generation (browser-compatible)
* Uses a basic hash algorithm instead of crypto.createHash
*/
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
/**
* Thrown by `updateData` / `deleteData` when the caller supplies an
* `expectedVersion` that does not match the current record's `updated_at`.
*
* The HTTP layer maps this to `409 Conflict` with code `CONCURRENT_UPDATE`,
* and includes both the current server-side version and the current record
* payload so the client can render an informed conflict-resolution UI
* ("Reload latest" vs. "Overwrite anyway").
*
* NOTE: This is an *application-level* compare-and-set — not an atomic
* storage-layer CAS. There is a small TOCTOU window between the version
* check and the subsequent write. For the conflict frequency this targets
* (different users seconds-to-minutes apart in B2B record editing) this
* is more than adequate; a future revision can push the check into the
* driver's UPDATE statement (`WHERE id=? AND updated_at=?`) for true
* atomicity.
*/
export class ConcurrentUpdateError extends Error {
readonly code = 'CONCURRENT_UPDATE';
readonly status = 409;
readonly currentVersion: string | null;
readonly currentRecord: unknown;
constructor(opts: { currentVersion: string | null; currentRecord: unknown; message?: string }) {
super(opts.message ?? 'Record was modified by another user');
this.name = 'ConcurrentUpdateError';
this.currentVersion = opts.currentVersion;
this.currentRecord = opts.currentRecord;
}
}
/**
* Normalises a version token for comparison. Strips RFC-7232-style quotes
* (`"…"`) that an HTTP `If-Match` header may carry, trims whitespace, and
* returns null for empty / nullish input.
*/
function normaliseVersionToken(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
if (!s) return null;
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, -1);
}
return s;
}
// Lifecycle columns the engine always owns; the clone path drops them by NAME
// so the insert re-stamps fresh values instead of copying the source's. Mirrors
// record-validator's SKIP_FIELDS (system-injected, never author-supplied).
const CLONE_STRIP_FIELDS: readonly string[] = [
'id', 'created_at', 'created_by', 'updated_at', 'updated_by',
];
/**
* [#3043] Drop caller-supplied writes to statically `readonly: true` fields from
* an INSERT payload, at the external DATA-WRITE INGRESS.
*
* #2948/#3003 made static `readonly` server-enforced on UPDATE (the engine strips
* a non-system caller's write). INSERT was left exempt — but for approval/status
* columns that exemption is the SHORTER attack: instead of the #3003
* draft-then-PATCH move, a non-system caller can POST a record already
* `approval_status: 'approved'` in one step. This closes it symmetrically, but at
* the INGRESS rather than in the engine: every EXTERNAL programmatic create — the
* REST CRUD route, the GraphQL/MCP dispatcher (`bridge.create` → `callData` →
* here), and bulk import — lands in the DataProtocol, while TRUSTED internal
* writers (better-auth's adapter, the metadata repository, the seed loader) call
* `engine.insert` DIRECTLY and never pass through here. Keeping the strip at the
* ingress therefore protects every agent/caller path at once WITHOUT stripping
* the internal writers that legitimately seed read-only columns on create
* (identity provisioning, provenance stamps, event-log cursors) — the blast
* radius an engine-level insert strip would have.
*
* Silent by contract (like the UPDATE / `readonlyWhen` strips): the forged key is
* dropped, the create still succeeds, and the engine re-derives the field's
* `defaultValue` (a forged `approval_status` becomes `draft`, the enforced
* initial state, not NULL). `isSystem` writes are exempt. `readonlyWhen` stays
* INSERT-exempt (a conditional lock needs a prior record, which a create lacks).
* Handles a single record or a batch array.
*
* SCOPE — author-defined business objects only. PLATFORM objects (`managedBy`
* set, or the reserved `sys_` namespace) carry their OWN field-write governance
* that a silent strip must not pre-empt: e.g. ADR-0086 REJECTS (403) a forged
* `managed_by:'package'` / `package_id` on `sys_permission_set`, and #3004
* rejects a forged `owner_id` anchor — several of those columns are `readonly`,
* so stripping them here would silently swallow the payload the guard is meant to
* reject. The #3043 threat is app approval/status/verdict fields (the issue's
* `sporadic_application` / `assessment`), never `sys_`; this is the same
* platform-vs-authored boundary `applySystemFields` uses for ownership.
*/
function stripReadonlyForInsert(schema: any, data: any, context: any): any {
if (context?.isSystem) return data;
if (!schema || schema.managedBy || String(schema.name ?? '').startsWith('sys_')) return data;
const fields = schema?.fields;
if (!fields || data == null) return data;
const stripRow = (row: any): any => {
if (row == null || typeof row !== 'object') return row;
let out = row;
for (const name of Object.keys(fields)) {
if (!fields[name]?.readonly) continue;
if (!(name in out)) continue;
if (out === row) out = { ...row };
delete out[name];
}
return out;
};
return Array.isArray(data) ? data.map(stripRow) : stripRow(data);
}
/**
* [#3431] Recover a `DroppedFieldsEvent` from a before/after write-payload diff.
*
* The UPDATE strips (static `readonly` / `readonlyWhen`) run INSIDE the engine,
* which reports them via the `onFieldsDropped` listener (wired in `updateData`).
* The CREATE `readonly` strip, however, runs at THIS protocol ingress
* (`stripReadonlyForInsert`, #3043) — BEFORE the engine — so the engine listener
* never sees it. Diffing the caller-supplied keys against the stripped payload
* recovers exactly which supplied fields the ingress strip removed, so the create
* path can surface them symmetrically with update.
*
* Returns `null` when nothing was dropped (same reference, non-object, array, or
* no key delta) so callers can `if (ev) dropped.push(ev)` without emitting empty
* events. Mirrors the engine's own before/after key-set diff (`reportDroppedFields`
* in objectql/engine.ts) so both channels agree on what "dropped" means.
*/
function diffDroppedFields(
object: string,
before: unknown,
after: unknown,
reason: DroppedFieldsEvent['reason'],
): DroppedFieldsEvent | null {
if (before === after || before == null || typeof before !== 'object' || Array.isArray(before)) return null;
const afterObj = (after ?? {}) as Record<string, unknown>;
const fields = Object.keys(before as Record<string, unknown>).filter((k) => !(k in afterObj));
return fields.length > 0 ? { object, fields, reason } : null;
}
/**
* [#3455] Collapse a batch's per-row `DroppedFieldsEvent`s into one event per
* `(object, reason)` with the UNION of dropped field names.
*
* Used by the bulk-create surface (`createManyData`), whose `{ object, records,
* count }` response has no per-row slot to hang a `droppedFields` on. The
* insert-ingress strip (#3043) is static-`readonly` only — schema-uniform, so
* every row drops the same set — which makes an aggregated view faithful rather
* than lossy. Returns `[]` when nothing was dropped so callers can spread
* `...(x.length ? { droppedFields: x } : {})` and keep the omit-when-empty shape.
* The per-row `insertMany`/`batch` paths keep row precision instead (they have a
* per-row result to carry it).
*/
function mergeDroppedFieldEvents(events: DroppedFieldsEvent[]): DroppedFieldsEvent[] {
if (events.length === 0) return [];
const byKey = new Map<string, { object: string; reason: DroppedFieldsEvent['reason']; fields: Set<string> }>();
for (const ev of events) {
const key = `${ev.object}|${ev.reason}`;
let bucket = byKey.get(key);
if (!bucket) { bucket = { object: ev.object, reason: ev.reason, fields: new Set() }; byKey.set(key, bucket); }
for (const f of ev.fields) bucket.fields.add(f);
}
return Array.from(byKey.values()).map((b) => ({ object: b.object, fields: Array.from(b.fields), reason: b.reason }));
}
/**
* The canonical `QueryAST` surface (`spec/data/query.zod.ts`), enumerated.
*
* Typed as `Record<keyof QueryAST, true>` so `tsc` pins it to the spec in BOTH
* directions: a key added there is a missing-property error here, a key removed
* there is an excess-property error here. That matters because the set below
* decides what is a query parameter and what is a field filter — silently
* drifting from the AST would resurrect exactly the #4134 failure for whatever
* key was added.
*
* The #4286 tombstones (`joins`, `windowFunctions`) still count: `retiredKey()`
* keeps a retired key in `keyof QueryAST`, so both stay listed — and therefore
* deliberately stay RESERVED at this boundary while the tombstone lives. That
* is the right compat posture: a caller still sending one belongs with the
* prescription, not with a silent `where.joins` filter. When a tombstone ages
* out (~two majors) and the key leaves the spec, the excess-property error
* here is the reminder that deleting its line UN-reserves the name — an object
* field genuinely called `joins` would start resolving as an implicit filter,
* which is a behavior change to call out in that changeset.
*/
const QUERY_AST_KEYS: Readonly<Record<keyof QueryAST, true>> = {
object: true, fields: true, where: true, search: true, searchFields: true,
orderBy: true, limit: true, offset: true, top: true, cursor: true,
joins: true, aggregations: true, groupBy: true, having: true,
windowFunctions: true, distinct: true, expand: true,
};
/**
* [#4254] The two aggregation vocabularies, read off the SPEC's own enums so a
* function or granularity added there is admitted here without a second edit —
* the same both-directions pinning `QUERY_AST_KEYS` gets from `keyof QueryAST`.
* They exist because the in-memory aggregation path answers an unknown member
* with a silent placeholder (`null` result / raw-value buckets) rather than an
* error, so the ingress must be the layer that refuses one.
*/
const AGGREGATION_FUNCTIONS: ReadonlySet<string> = new Set(AggregationFunction.options);
const DATE_GRANULARITIES: ReadonlySet<string> = new Set(DateGranularity.options);
/**
* [#4134] Every query-parameter name `findData` consumes itself, consulted
* AFTER the alias normalization in `findData` has run — so the wire spellings
* that get rewritten (`$top`→`top`→`limit`, `select`→`fields`, `sort`→
* `orderBy`, `filter`/`filters`/`$filter`→`where`, `populate`/`$expand`→
* `expand`, `skip`→`offset`, …) are already gone by this point and
* deliberately do NOT appear here. Anything still standing is either a name in
* this set or a candidate field filter.
*
* The structural AST keys (`object`, `joins`, `having`, `windowFunctions`)
* matter even though no querystring carries them: `POST /data/:object/query`
* hands its body in as `query`, and that body IS a `Partial<QueryAST>`. Without
* them, `client.data.query('task', { object: 'task', limit: 5 })` would have
* its `object` key read as a filter and match zero rows.
*
* A name in this set can never be used as an implicit field filter, so an
* object with a field genuinely called e.g. `count` or `cursor` must filter it
* through the explicit form (`?filter={"count":3}`). That trade-off predates
* #4134 for the original members; it is called out here so the next person to
* add one knows what they are spending.
*/
const RESERVED_LIST_QUERY_PARAMS: ReadonlySet<string> = new Set([
...Object.keys(QUERY_AST_KEYS),
// Transport-only extras the normalizer consumes but the AST does not name.
'count', // ?count / $count — response flag, not a projection
// `searchFields` used to be listed here as such an extra. It is a named
// AST key since #3899 declared it (ADR-0061 P1), so it now arrives through
// the spread above and the type-level pin covers it — the hand-maintained
// copy would have been a second source that could silently fall out of step.
// Server-derived, never caller input (stripped then re-set from `request`).
'context',
]);
/**
* [#4134] High-frequency wrong guesses → the parameter that actually works.
* Keys are normalized (lower-cased, `_`/`-` stripped) so `pageSize`,
* `page_size` and `PAGE-SIZE` all land on the same entry.
*
* This is a HINT table, not an alias table: nothing here is accepted as input.
* Adding an entry makes a rejection more helpful; it never makes a request
* succeed, so it does not create the second de-facto contract Prime Directive
* #12 warns about.
*/
const QUERY_PARAM_NEAR_MISS: Readonly<Record<string, string>> = {
// page-size dialects (the #4134 repro: `?pageSize=5` → 200 + empty list)
pagesize: 'top', persize: 'top', perpage: 'top', pagelimit: 'top',
rowsperpage: 'top', pagecount: 'top', size: 'top', take: 'top',
first: 'top', max: 'top', maxresults: 'top', maxrecords: 'top',
// page-offset dialects
page: 'skip', pageno: 'skip', pagenum: 'skip', pagenumber: 'skip',
pageindex: 'skip', start: 'skip', startindex: 'skip', startat: 'skip',
// sorting
sortby: 'sort', sortfield: 'sort', sortorder: 'sort', order: 'sort',
ordering: 'sort',
// search
q: 'search', keyword: 'search', keywords: 'search', term: 'search',
searchterm: 'search', querytext: 'search',
// filtering
filterby: 'filter', criteria: 'filter', conditions: 'filter',
// projection / relations
columns: 'select', include: 'expand', includes: 'expand', with: 'expand',
};
/**
* The OData spelling of each parameter {@link QUERY_PARAM_NEAR_MISS} points at.
* NOT derivable by prefixing `$` — `sort` is `$orderby`, and suggesting a
* `$sort` that the unsupported-`$` guard rejects would just hand the caller a
* second 400.
*/
const ODATA_SPELLING: Readonly<Record<string, string>> = {
top: '$top', skip: '$skip', sort: '$orderby', search: '$search',
filter: '$filter', select: '$select', expand: '$expand',
};
/**
* [#3795] The spec's alias table ({@link RPC_QUERY_ALIAS_SLOTS}) extended with
* the wire-only spellings no schema declares: `filters` (documented plural
* alias of the `filter` transport param) and the OData `$filter` / `$expand`.
* Every spelling of one QueryAST slot resolves through ONE fold — the four
* slots that used to resolve backwards (canonical consulted last), each in its
* own open-coded way, are the reason the table lives in the spec and not here.
*/
const WIRE_QUERY_ALIAS_SLOTS: readonly QueryAliasSlot[] = (() => {
const extra: Record<string, readonly string[]> = {
where: ['filters', '$filter'],
expand: ['$expand'],
};
return RPC_QUERY_ALIAS_SLOTS.map((slot) => ({
canonical: slot.canonical,
aliases: [...slot.aliases, ...(extra[slot.canonical] ?? [])],
}));
})();
/**
* [#4181 → #3795] Spellings of ONE slot carrying DIFFERENT values. Two values
* for one slot cannot be reconciled — merging them would invent an intent the
* caller never expressed, and picking one is the silent drop itself — so an
* ambiguous request is refused. Redundant identical spellings pass. #4181
* established this on the filter slot; the fold now applies it to all five.
*
* `spellingFor` maps each folded name back to the wire spelling the caller
* actually wrote (`$orderby`, not `orderBy`) — the #4226 discipline.
*/
function conflictingQueryParamsError(
conflict: QueryAliasConflict,
spellingFor: (name: string) => string,
): Error {
const names = conflict.spellings.map((s) => `'${spellingFor(s)}'`).join(', ');
const err: any = new Error(
`Conflicting query parameters: ${names} are spellings of the same parameter `
+ `(canonical '${conflict.canonical}') and were given different values. Send exactly one.`,
);
err.status = 400;
err.code = 'INVALID_REQUEST';
return err;
}
/**
* [#4181] A filter the normalizer cannot turn into a usable `FilterCondition`
* by any route other than the array shapes {@link malformedFilterArrayError}
* already diagnoses: unparseable JSON, or JSON that parses to something no
* driver can read (a number, a bare string, `null`).
*
* Carries `INVALID_FILTER` — the standard-catalog code (`errors.zod.ts`,
* "Invalid filter expression") that #4121 introduced on this same code path for
* the array case. One condition, one wire code, however the caller reached it:
* a `$filter` array with a bad operator and a `?filter=` that is not JSON are
* the same answer to the same question ("this filter cannot run").
*
* The message states the filter was NOT APPLIED, because that is the part a
* caller cannot infer: the pre-#4181 behavior was an ordinary-looking 200 over
* the unfiltered set.
*/
function unusableFilterError(param: string, detail: string): Error {
const err: any = new Error(
`Query parameter '${param}' ${detail}. It was not applied, and an unapplied `
+ 'filter would have returned the unfiltered result set.',
);
err.status = 400;
err.code = 'INVALID_FILTER';
err.param = param;
return err;
}
/**
* [#4226] A sort the normalizer cannot turn into a usable `SortNode[]`, or one
* that names a field the object does not have — or, since #4256, a dotted path
* (`account.company_name`) that would have to cross into a related record no
* driver joins for.
*