Repository navigation
Expand file tree
/
Copy pathplugin.ts
More file actions
1684 lines (1610 loc) · 89.9 KB
/
Copy pathplugin.ts
File metadata and controls
1684 lines (1610 loc) · 89.9 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 { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { Plugin, PluginContext } from '@objectstack/core';
// ADR-0130 D4/D5 — the ONE reader of a release artifact's `packages[]`.
// It lives in `@objectstack/core` rather than in the load path that first
// needed it because `@objectstack/objectql` depends on THIS package, so the
// artifact door could not import it from there; hosting it in a package both
// readers already depend on adds no edge to the graph. Reused, never
// re-derived: it is the ordering (D5) AND the entry gate, so a second read of
// `packages[]` here would make the two readers disagree about which artifacts
// are loadable at all.
import { resolveArtifactPackageOrder, artifactPackageId } from '@objectstack/core';
import { NodeMetadataManager } from './node-metadata-manager.js';
import { MemoryLoader } from './loaders/memory-loader.js';
import { DEFAULT_METADATA_TYPE_REGISTRY, judgeCollectionItemOwnerStamp, OWNER_STAMP_JUDGED_COLLECTIONS } from '@objectstack/spec/kernel';
import type { MetadataPluginConfig } from '@objectstack/spec/kernel';
import { applyProtection } from '@objectstack/spec/shared';
import {
SysMetadataObject,
SysMetadataHistoryObject,
SysMetadataCommitObject,
SysMetadataAuditObject,
applyArtifactForwardConversions,
detectUnboundFormViewPredicateRoots,
BOUND_FORM_VIEW_PREDICATE_ROOTS,
BOUND_FORM_FIELD_PREDICATE_ROOTS,
type ArtifactForwardConversionResult,
type ArtifactForwardConversionVerdict,
} from '@objectstack/metadata-core';
/**
* Which forward-conversion verdicts mean the versioned window OPENED for an
* artifact — the one reading both artifact-door notices below take, off the
* pass's own verdict rather than a second comparison of their own.
*
* A total table rather than a guard naming only some verdicts: a verdict added
* to `ArtifactForwardConversionVerdict` is a compile error here until someone
* decides which side of the window it is on. The guard this replaced named the
* open verdicts by hand, and when the per-entry half of the window
* (`'converted-retired-after'`) arrived it silently fell on the closed side, so
* the #12915 notice depended on the package label again.
*/
const FORWARD_WINDOW_OPENED: Readonly<Record<ArtifactForwardConversionVerdict, boolean>> = {
'converted-forward': true,
'converted-undeclared': true,
'converted-retired-after': true,
'converted-major-step': true,
'authored-current': false,
'runtime-version-unknown': false,
'not-an-object': false,
};
/**
* The clause both artifact-door notices open with: why this artifact's window
* opened. Under the per-entry half the floor is NOT below the runtime's
* label — `main` enforces retirements its label has not moved past — so
* "predates this runtime's spec", printed beside a runtime version equal to
* the floor, would read as a contradiction; that verdict says what actually
* opened it instead. So does the major-step half, whose floor sits ABOVE a
* prerelease runtime's label: it names the floor as the first release of its
* protocol major, which a build from that major's prerelease declares too.
*/
function artifactWindowClause(result: ArtifactForwardConversionResult<unknown>): string {
const floor = result.authoredFloor ?? '<undeclared>';
if (result.verdict === 'converted-major-step') {
const major = floor.split('.')[0];
return `declares the first release of protocol ${major} (authored engines.protocol floor ${floor}), `
+ `which a build from any ${major}.0.0 prerelease declares too, so it may predate retirements in the `
+ `conversion step into protocol ${major} that this runtime enforces (runtime spec ${result.runtimeSpecVersion})`;
}
if (result.verdict === 'converted-retired-after') {
return `was built on a surface that still accepted shapes this runtime has since retired `
+ `(authored engines.protocol floor ${floor}, at or below the last release that accepted them; `
+ `runtime spec ${result.runtimeSpecVersion}, whose label has not moved past that release)`;
}
return `predates this runtime's spec (authored engines.protocol floor ${floor}, runtime spec `
+ `${result.runtimeSpecVersion})`;
}
// `SysMetadataObject` + `SysMetadataHistoryObject` are the customer overlay
// storage substrate (ADR-0005). They must always be auto-provisioned so
// `PUT /api/v1/meta/{view,dashboard}/...` has a place to write. All other
// metadata types (object/view/flow/agent/tool/dashboard/app/...) live as
// JSON inside `sys_metadata` — there are no separate per-type tables. The
// previously shipped `SysObject` / `SysView` / `SysFlow` / `SysAgent` /
// `SysTool` projection objects were removed in 2026-05 (see ADR 0005
// addendum); the projection pipeline was removed at the same time.
//
// `SysMetadataAuditObject` (ADR-0010) is the append-only audit trail for
// metadata write decisions — provisioned alongside the storage tables so
// `_lock` enforcement always has a place to record decisions, even when
// the deployment skipped @objectstack/plugin-audit.
//
// `SysMetadataCommitObject` (ADR-0067) is the package-scoped commit log that
// GROUPS a turn's `sys_metadata_history` events (the unit `revertCommit`
// operates on). It MUST be provisioned alongside its history sibling: every
// publish/apply writes a commit row, so a per-project (cloud) env kernel that
// omitted it hit `no such table: sys_metadata_commit` on the first AI build —
// the write is best-effort so the build still landed, but the error spammed
// logs and the commit timeline silently recorded nothing. Registered here
// (not only in the ObjectQLPlugin `environmentId === undefined` standalone
// path) so env kernels — the only place this table is written — get it.
const queryableMetadataObjects = [
SysMetadataObject,
SysMetadataHistoryObject,
// ADR-0067 commit log — sibling of sys_metadata_history (see note above).
SysMetadataCommitObject,
SysMetadataAuditObject,
// ⛔ No `sys_view_definition`: it retired as inert under ADR-0131 D13 — no
// framework writer or reader of its rows ever existed, and runtime-authored
// views are `view` items written through `PUT /api/v1/meta/view/...` into
// `sys_metadata` like every other overlay.
];
// Subdirectory under `rootDir` reserved for the ADR-0008 repository's
// canonical JSON storage + JSONL change log. Kept separate from user
// source code (which the legacy FilesystemLoader still scans).
const REPO_SUBDIR = '.objectstack/metadata';
// Map from ObjectStackDefinition field name to MetadataType name.
//
// PINNED against the schema: `scripts/check-stack-collection-maps.mjs` reconciles
// this map with `ObjectStackDefinitionSchema` in both directions, and every
// deviation carries a reason there (#6242). It is one of seven hand-maintained
// enumerations of the same set, and the reason this one has a gate is its own
// history — `docs` and `roles → positions` were each fixed here one line at a
// time, after a missing key silently dropped a whole collection.
const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
objects: 'object',
objectExtensions: 'object_extension',
apps: 'app',
views: 'view',
pages: 'page',
dashboards: 'dashboard',
reports: 'report',
// [ADR-0021, ADR-0130 D4] The analytics datasets a dashboard or report names.
// Mapped so the residual rule walks them too: without this entry a
// `packages[]` stack's top-level dataset that no body declared was neither
// registered nor refused, and the dashboard naming it failed at run time.
// Not the seed `data:` collection, which stays unmapped (see the end of
// this map).
datasets: 'dataset',
actions: 'action',
// `themes: 'theme'` removed by commit 35ad101bc (ADR-0049): the carrier key is
// retired, so a parsed artifact can no longer carry the field — and the
// ingest half of the dead pipeline (items stored, read by nothing) goes
// with the authoring half rather than surviving it as drift.
workflows: 'workflow',
flows: 'flow',
// ADR-0090 D3: stacks declare `positions` (stack.zod.ts); the retired
// `roles: 'role'` mapping matched nothing and silently dropped compiled
// positions from artifact ingestion.
positions: 'position',
permissions: 'permission',
// [ADR-0066 D1] `capabilities` reaches the door at #12892 step 1, the
// maintainer's `option 1` ruling ("the door owns the registration
// route" for the five artifact security collections). Until #12894
// measured it, `AppPlugin`'s `SECURITY_FIELDS` block
// (packages/runtime/src/app-plugin.ts) was this collection's SOLE
// registrar on an artifact boot — the one security collection the door
// could not reach — so a declared capability was registered from bytes
// nothing strict-parses, with no schema default and no ADR-0010
// provenance. Measured on the two-reader harness, the door's copy adds
// exactly four keys the raw copy lacks: `scope` (the schema default)
// and `_packageId` / `_packageVersion` / `_provenance`.
//
// ⚠️ This entry makes the door a SECOND writer, not yet the only one:
// `AppPlugin` still registers `capabilities`, and it runs last, so the
// raw copy still wins a real artifact boot. Step 2 of the ruling (that
// block stops registering these five on the artifact path, after a
// census of the non-artifact boot paths) is what makes this the only
// copy. Until then the divergence is the interim reality the ruling
// explicitly permits, and #12878's pins are what keep it visible.
capabilities: 'capability',
sharingRules: 'sharing_rule',
// [ADR-0142 D1/D5] Declared ownership queues and assignment rules, beside the
// sharing rules that can name a queue as their recipient.
queues: 'queue',
assignmentRules: 'assignment_rule',
// `policies: 'policy'` removed at #12894: the stack schema is a
// `strictObject` that declares no top-level `policies` key, so a
// definition carrying one is refused by the strict parse a few lines
// below — the entry could never match, and nothing was ever registered
// under `policy` from this map. The word is real, but it lives ONE LEVEL
// DOWN: on a permission set it is an alias for `rowLevelSecurity`
// (`PERMISSION_SET_KEY_ALIASES`, packages/spec/src/security/permission.zod.ts)
// — a key on an ITEM, never a collection. Third retirement of this exact
// shape in this map (`themes` and `roles` above); the reasons are kept
// in place because the first two are what made this one findable.
apis: 'api',
webhooks: 'webhook',
agents: 'agent',
tools: 'tool',
skills: 'skill',
ragPipelines: 'rag_pipeline',
hooks: 'hook',
mappings: 'mapping',
// Shared option lists. Registered as items here so the artifact boot
// serves `GET /meta/picklist` like every other kind; what a FIELD is
// served with is resolved by the ObjectQL registry, which also merges
// `picklistExtensions` into the list they name — that collection is not a
// kind of its own and has no entry here (see check:stack-collection-maps).
picklists: 'picklist',
analyticsCubes: 'analytics_cube',
connectors: 'connector',
emailTemplates: 'email_template',
docs: 'doc',
books: 'book',
// `data:` (the SEED collection) is deliberately absent — #6242 row 4(a).
// It used to map to `'dataset'`, the ADR-0021 analytics kind: the exact name
// collision `metadata-plugin.zod.ts` warns about in prose. The entry never
// registered anything (SeedSchema declares no `name`, and the loop below
// skips nameless items) — a dead pointer aimed at the wrong kind, which
// would have begun mis-registering the day either side moved. Removed rather
// than repointed at `'seed'`: seeds are APPLIED by SeedLoaderService off the
// bundle, never registered as metadata items, so a `seed` mapping would be
// new behaviour rather than a corrected name.
};
// ───────────────────────────────────────────────────────────────────────────
// View container expansion — "Object has-many View" (ADR-0017)
// ───────────────────────────────────────────────────────────────────────────
//
// `defineView({ list, form, listViews, formViews })` aggregates every view of
// an object into one document. The loader expands such a container into N
// independent ViewItems (one per named view) registered under
// `<object>.<viewKey>`, so each view is individually addressable and the
// runtime switcher can be rebuilt by querying `object`. The original container
// is ALSO kept under the bare `<object>` key for backward-compatible reads.
//
// The implementation lives in `@objectstack/spec` (`isAggregatedViewContainer`
// / `expandViewContainer`) so this HMR loader and the ObjectQL engine boot loop
// share ONE canonical expansion and can never drift. Re-exported here for the
// callers below and for `view-expand.test.ts`.
export { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
// [#13912] WHICH object a container binds to is a separate question from HOW it
// expands, and it is the one that had drifted here: this file walked two levels
// (`list.data.object` then `form.data.object`) and never read the container's
// own top-level `object` — the field `ViewSchema.object` documents as the
// authorial signal "read by getViewsByObject() / GET /meta/view?object=". The
// one spelling of that derivation for this package already exists:
// `view-container-expansion.ts`'s `deriveViewContainerObject`, whose header
// declares it to be the single place the drift is repaired rather than a third
// private copy to fall behind. Both sites below call it instead of restating a
// chain of their own; it carries the same order #13407 settled at the runtime
// door (`expandRuntimeViewContainer` in `packages/metadata-protocol`).
import { deriveViewContainerObject } from './view-container-expansion.js';
// [#21412] The divergent container `name` refusal — the one judge the boot
// loop, `os validate` and the runtime save door call too. See the container
// branch of `registerArtifactBodyCollections`.
import { viewContainerNameRefusal } from './view-container-name.js';
import type { IHttpServer } from '@objectstack/spec/contracts';
// ───────────────────────────────────────────────────────────────────────────
// The residual rule — ONE rule, for both boot doors (ADR-0130 D4)
// ───────────────────────────────────────────────────────────────────────────
//
// A stack that carries `packages[]` registers each package BODY under that
// package's id. What its TOP LEVEL carries that no body declares is the
// residual: it is registered under the stack's own `manifest.id`, and the boot
// says so. Two doors reach a stack:
//
// - the ARTIFACT door — `MetadataPlugin`'s load of a compiled
// `objectstack.json` (`_parseAndRegisterArtifact` below);
// - the CONFIG door — `os serve objectstack.config.ts`, whose `AppPlugin`
// hands the stack to the `manifest` service, which registers the package
// bodies (`resolveArtifactPackageOrder` answers `packages[]` alone when
// the key is present) and, of the top level, only the residual's objects
// (the engine half, below). Before this rule had a
// second caller, a config boot served such an item through no door and
// said nothing, while an artifact boot of the same project served it
// under `manifest.id` and warned.
//
// Both call {@link MetadataPlugin.registerUnclaimedTopLevel}, so which items
// are residual, which id they take, how each is registered and what the boot
// says are decided here and nowhere else. ⛔ Never a second copy at a door.
//
// The ENGINE half reads the same answer. Both doors hand the whole stack to
// the `manifest` service (`@objectstack/objectql`), which registers the bodies
// and then, from {@link unclaimedTopLevel}, the residual's OBJECTS under the
// same id, so the data door serves the objects this rule lists. An object it
// cannot register without refusing the boot (a name another package already
// owns, a field naming a picklist no body declares) stays listed here and
// unserved there, and the engine names it at boot.
//
// The residual's PICKLISTS are the one exception to "registered under the
// stack's id": this rule lists them, and the engine never registers them, so
// a residual picklist is metadata only. When a residual object's field names
// it, that object is listed and not served, as above; when a body's field
// names it, the boot's picklist audit refuses that field (`os validate` /
// `os build` report it as `picklist-reference-unknown`). When no object field
// the engine registers names it, the engine refuses the stack
// before anything of it registers (`INVALID_METADATA`), and `os validate` /
// `os build` refuse the same stack first.
/**
* Where one door's registrations land. The artifact door writes its in-memory
* loader and the `MetadataManager`; the config door writes the metadata
* service's in-memory registry (`AppPlugin`), the route it already uses for
* the code-owned items it surfaces. ⛔ The sink only STORES: what is
* registered, under which key and with which owner stamp is
* {@link registerArtifactBodyCollections}'s decision, made once for both.
*/
export interface ArtifactRegistrationSink {
/** Store one item under `(type, name)`. It arrives already stamped with its owner. */
register(type: string, name: string, item: unknown): Promise<void> | void;
/** Say one line at `warn`. */
warn(message: string): void;
}
/** Which door a stack arrived through — chooses the words, never the rule. */
export interface UnclaimedTopLevelSource {
door: 'artifact' | 'config';
/** The artifact's path or URL; the config's manifest id. */
label: string;
}
/**
* The `(type, name)` slot one item occupies in the registry, derived the one
* way both the registration and the claim walk derive it.
*
* A `defineView` container has no own `name`: it is filed under the object it
* binds to (`deriveViewContainerObject`, #13912) and expands into one view
* item per named view. Most other items carry a top-level `name`; a view
* written without one falls back to the same object derivation. An item with
* no derivable slot is not registered, and so claims nothing.
*/
function artifactItemSlot(
metaType: string,
item: unknown,
): { container: true; object: string } | { container: false; name: string } | undefined {
if (metaType === 'view' && isAggregatedViewContainer(item)) {
const object = deriveViewContainerObject(item);
return object ? { container: true, object } : undefined;
}
let name = (item as any)?.name;
if (!name && metaType === 'view') name = deriveViewContainerObject(item);
return name ? { container: false, name } : undefined;
}
/**
* Every `(type, name)` slot the package bodies register: a container's own
* slot and each view it expands into, every named item's slot. Read only to
* tell "the top level repeats what a package already owns" (the normal case —
* `composeStacks(…, { manifest: 'preserve' })` is ADDITIVE, so the flattened
* top level repeats every body) from "the top level carries something no
* package declares".
*/
function claimedArtifactSlots(bodies: readonly unknown[]): (type: string, name: string) => boolean {
const owned = new Map<string, Set<string>>();
const claim = (type: string, name: string): void => {
let names = owned.get(type);
if (!names) owned.set(type, names = new Set<string>());
names.add(name);
};
for (const body of bodies) {
for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
const items = (body as any)?.[field];
if (!Array.isArray(items)) continue;
for (const item of items) {
const slot = artifactItemSlot(metaType, item);
if (!slot) continue;
if (!slot.container) {
claim(metaType, slot.name);
continue;
}
claim('view', slot.object);
for (const vi of expandViewContainer(slot.object, item)) claim('view', vi.name);
}
}
}
return (type, name) => owned.get(type)?.has(name) === true;
}
/** One top-level item of a multi-package stack that none of its package bodies declares. */
export interface UnclaimedTopLevelItem {
/** The stack collection the item sits in, e.g. `views`. */
field: string;
/** Its position in that collection, e.g. `0` for `views[0]`. */
index: number;
/** The metadata type it registers as, e.g. `view`. */
type: string;
/** The item itself, as the stack carries it. */
item: unknown;
}
/** A multi-package stack's residual, and the identity it is registered under. */
export interface UnclaimedTopLevel {
/** The stack's own manifest id — the owner of every unclaimed item. */
ownerId: string | undefined;
/** The stack's own manifest version, stamped beside that id. */
ownerVersion: string | undefined;
/** How many package bodies the stack carries. */
bodyCount: number;
/** The unclaimed items, in the order a door registers them. */
items: UnclaimedTopLevelItem[];
}
/**
* WHICH top-level items of a stack that carries `packages[]` no package body
* declares, and under which id they are registered — the decision half of the
* residual rule, with nothing registered and nothing thrown.
*
* {@link MetadataPlugin.registerUnclaimedTopLevel} registers exactly these, in
* this order, through either boot door, and the engine's `manifest` service
* registers the objects among them under the same id. `os validate` / `os build`
* (`@objectstack/cli`'s view-container walk) read the same answer to judge what
* that registration will judge, so an author-time door and a boot door cannot
* disagree about which items are residual. ⛔ Never re-derived by a caller.
*
* An item is unclaimed when it occupies a registry slot (`artifactItemSlot`)
* that no package body occupies (`claimedArtifactSlots`): a container is
* compared on its own slot, every other item on its name. An item with no
* derivable slot is registered by no door, so it is not listed.
*
* @returns `undefined` for a stack without `packages[]`: its top level IS its
* one package's body, so it has no residual.
*/
export function unclaimedTopLevel(stack: unknown): UnclaimedTopLevel | undefined {
if (!Array.isArray((stack as any)?.packages)) return undefined;
const bodies = resolveArtifactPackageOrder(stack);
const claimed = claimedArtifactSlots(bodies);
const items: UnclaimedTopLevelItem[] = [];
for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
const collection = (stack as any)[field];
if (!Array.isArray(collection)) continue;
collection.forEach((item: unknown, index: number) => {
const slot = artifactItemSlot(metaType, item);
if (!slot) return;
if (claimed(metaType, slot.container ? slot.object : slot.name)) return;
items.push({ field, index, type: metaType, item });
});
}
return {
ownerId: (stack as any)?.manifest?.id ?? (stack as any)?.id ?? undefined,
ownerVersion: (stack as any)?.manifest?.version ?? (stack as any)?.version ?? undefined,
bodyCount: bodies.length,
items,
};
}
/**
* Register ONE body's collections through `sink`.
*
* A "body" is the whole artifact (the single-package branch, where the
* artifact and its one package are the same object), one entry of
* `packages[]` (ADR-0130 D4) — an assembled `{ ...manifest, ...collections }`
* payload carrying the same collection keys the top level does — or a
* multi-package stack's residual ({@link unclaimedTopLevel}). The loop is
* identical for all three: there is one ingestion of a collection, not one per
* shape or per door.
*
* @param provenance - The `(packageId, packageVersion)` every item found in
* this body is stamped with (ADR-0010 §3.7, via `applyProtection`). It is
* the body's OWN identity, never the enclosing artifact's, which is what
* makes a multi-package artifact's items agree with the registry and with
* `GET /api/v1/packages` about who owns them.
* @param sourceLabel - The words naming the source in a divergent container
* `name` refusal: `artifact` for a compiled artifact; `manifest` for a
* config's residual, the word the config boot's own registrar uses for the
* same stack. Two items of one name inside one body still register as they
* always have (last wins): suppressing that would be a behaviour change on
* the single-package branch D7 pins.
* @returns How many items this body registered.
*/
/**
* [#28697] The owner-stamp refusal at this door: an item of a judged
* collection ({@link OWNER_STAMP_JUDGED_COLLECTIONS}) whose body already wears
* a `_packageId` other than the body's own package, judged by the one judge
* (`judgeCollectionItemOwnerStamp`, `@objectstack/spec/kernel`) in the
* registration door's words and envelope (`VALIDATION_ERROR` / 400, `issues[]`
* at `_packageId`, the envelope the engine's body seam throws).
*
* Why here: `applyProtection` below stamps an unset key only, so a body's own
* stamp used to be what this door stored, and the engine's boot sync
* (`ObjectQLPlugin.loadMetadataFromService`) files every `app` / `view` /
* `flow` / `hook` it reads back under that stamp. A stack the schema parsed
* was judged at the build door first; a body no parse judged (a config boot's
* residual from a `strict: false` stack) was not, so this door judges every
* body it registers. The collections are the build door's, so neither door
* refuses what the other accepts.
*/
function artifactItemOwnerStampRefusal(
field: string,
metaType: string,
name: string,
item: unknown,
packageId: string | undefined,
): (Error & { code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }> }) | undefined {
if (!(OWNER_STAMP_JUDGED_COLLECTIONS as readonly string[]).includes(field)) return undefined;
const refusal = judgeCollectionItemOwnerStamp(item, metaType, name, packageId, 'register');
if (!refusal) return undefined;
const err = new Error(refusal.message) as Error & {
code: 'VALIDATION_ERROR'; status: 400; httpStatus: 400; issues: Array<{ path: string; message: string; code: string }>;
};
err.code = 'VALIDATION_ERROR';
err.status = 400;
err.httpStatus = 400;
err.issues = [{ path: '_packageId', message: refusal.issueMessage, code: 'custom' }];
return err;
}
async function registerArtifactBodyCollections(
sink: ArtifactRegistrationSink,
body: Record<string, unknown>,
provenance: { packageId?: string; packageVersion?: string },
sourceLabel: string,
): Promise<number> {
const { packageId, packageVersion } = provenance;
let totalRegistered = 0;
for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
const items = (body as any)[field];
if (!Array.isArray(items) || items.length === 0) continue;
for (const item of items) {
const slot = artifactItemSlot(metaType, item);
if (!slot) continue;
// Expand aggregated view containers into independent ViewItems
// ("Object has-many View"), while still registering the
// container under the bare <object> key for backward-compatible
// reads. Already-independent ViewItems carry a top-level `name`
// and take the normal path below.
if (slot.container) {
// [#13912] `object` FIRST — a package-shipped
// `defineView({ object: 'crm_lead', list: { columns } })`
// sets the binding there and nowhere else, and the old
// two-deep chain skipped that container entirely, so it never
// reached the registry at all and `getViewsByObject()` had
// nothing to expand.
const viewObject = slot.object;
// [#21412] A container whose own `name` disagrees with the
// key derived above is refused through the one judge every
// door that files a container calls, in its words — and
// BEFORE the sink stores anything, so a refusal files nothing.
// `manager.register` would refuse the same document too
// (#7378 row 1, `assertMetadataRegisterContract`), but in the
// generic register contract's words and only after the loader
// write; row 1 is unchanged for every type.
const nameRefusal = viewContainerNameRefusal(item, sourceLabel, packageId);
if (nameRefusal) throw nameRefusal;
const ownerStamp = artifactItemOwnerStampRefusal(field, metaType, viewObject, item, packageId);
if (ownerStamp) throw ownerStamp;
applyProtection(item as any, {
packageId: packageId,
packageVersion: packageVersion,
});
await sink.register('view', viewObject, item);
totalRegistered++;
for (const vi of expandViewContainer(viewObject, item)) {
for (const w of vi._diagnostics?.warnings ?? []) {
sink.warn(`[MetadataPlugin] View expansion warning for '${vi.name}': ${w.message}`);
}
applyProtection(vi as any, {
packageId: packageId,
packageVersion: packageVersion,
});
await sink.register('view', vi.name, vi);
totalRegistered++;
}
continue;
}
// Most metadata items carry a top-level `name`. The `View`
// container (UI namespace) is an exception: it has no own
// `name` — its identity is the target object, declared in the
// container's own top-level `object` field and, for containers
// written before that field was read here, under
// `list.data.object` (or `form.data.object`). Mirror the
// resolution used by `ObjectQL.SchemaRegistry` so that
// artifact-loaded views land in `MetadataManager` under the
// SAME key reads expect (`metadataService.get('view', <object>)`).
// Without this, HMR pushes views into the registry only via
// AppPlugin's `manifest.register`, which targets the
// boot-only SchemaRegistry cache and is never refreshed on
// file edits — leaving MetadataService empty for view/* and
// forcing all reads to return the stale boot copy.
//
// [#13912] `deriveViewContainerObject` is that resolution, in
// the package's one spelling (`artifactItemSlot` reads it). Its
// last fallback — the row's own `name` — cannot fire on that
// branch (it is reached only when `item.name` is falsy), so what
// it adds there is exactly the leading `object` term this path
// was missing.
const name = slot.name;
const ownerStamp = artifactItemOwnerStampRefusal(field, metaType, name, item, packageId);
if (ownerStamp) throw ownerStamp;
// ADR-0010 §3.7 — translate the author-facing
// `protection` block into the private `_lock` envelope
// and stamp package provenance in one call. Strips the
// public block so it never lands in sys_metadata.
applyProtection(item as any, {
packageId: packageId,
packageVersion: packageVersion,
});
await sink.register(metaType, name, item);
totalRegistered++;
}
}
return totalRegistered;
}
/**
* The one line a boot prints when it registered a residual — the same
* sentence through either door, with the door's own noun and remedy: an
* artifact is rebuilt, a config is re-declared. The `top-level metadata
* item(s)` sentence is what operators grep for, and the artifact door's text
* is unchanged byte for byte.
*/
function unclaimedTopLevelWarning(
source: UnclaimedTopLevelSource,
residual: number,
bodyCount: number,
manifestPackageId: string | undefined,
): string {
const owner = manifestPackageId ?? '<none>';
if (source.door === 'artifact') {
return `[MetadataPlugin] artifact '${source.label}' carries ${residual} top-level metadata item(s) that `
+ `none of its ${bodyCount} package bodies declare. They were registered under the `
+ `artifact's own manifest id ('${owner}') because no package in the `
+ 'artifact claims them, so every door will report that id as their owner. Rebuild the '
+ 'artifact so each collection it ships is carried by the package that owns it.';
}
return `[AppPlugin] config stack '${source.label}' carries ${residual} top-level metadata item(s) that `
+ `none of its ${bodyCount} package bodies declare. They were registered under the `
+ `stack's own manifest id ('${owner}') because no package in the stack claims them, `
+ 'so every door will report that id as their owner. Declare each one inside the '
+ '`packages[]` entry of the package that owns it; a flat `src/docs/` page moves under '
+ '`src/<package>/docs/`, where `<package>` is the package id or its last dot-separated segment.';
}
export interface MetadataPluginOptions {
rootDir?: string;
/**
* When `true`, NodeMetadataManager scans `rootDir` for source-file metadata
* (yaml/json/ts/js loaders) AND attaches a chokidar watcher to react to
* filesystem changes. In **artifact-mode** (this is the normal path when
* a `defineStack()` config is compiled into `dist/objectstack.json`) this
* filesystem scan is redundant and expensive — leave `watch: false`.
*
* The artifact-file HMR watcher is controlled separately by
* {@link artifactWatch} so that the cheap, single-file polling watcher
* can be enabled in dev without paying the cost of scanning the entire
* project root.
*
* Default: `false` (post PR-10e — was previously `true`).
*/
watch?: boolean;
/**
* When `true` AND `artifactSource.mode === 'local-file'`, attach a
* polling chokidar watcher to the artifact file so the server reloads
* metadata when the CLI recompiles `dist/objectstack.json` in dev mode.
* Independent of {@link watch} (which controls the source-file scanner).
*
* Default: `true` when `artifactSource` is set, otherwise `false`.
*/
artifactWatch?: boolean;
config?: Partial<MetadataPluginConfig>;
/** Organization ID for metadata-scoped consumers; MetadataPlugin itself does not persist runtime metadata. */
organizationId?: string;
/**
* Environment ID used by local artifact envelopes and metadata-scoped
* consumers. (The v5.0 rename retired the "project ID" wording this
* comment used to carry; see ADR-0006.)
*/
environmentId?: string;
/**
* When set, MetadataPlugin loads metadata from a compiled artifact instead
* of scanning the filesystem, honored by all three bootstrap modes
* (`eager` / `lazy` / `artifact-only`) — see `start()`.
*
* `path` is a filesystem path, or an `http(s)://` URL fetched verbatim —
* the control plane's public artifact route
* (`/pub/v1/environments/:id/artifact[?commit=…]`) serves exactly such
* URLs, so a sealed runtime can boot straight off a published revision.
* Remote reads honor `fetchTimeoutMs` / `OS_ARTIFACT_FETCH_TIMEOUT_MS`;
* the artifact-file HMR watcher ({@link artifactWatch}) applies to
* non-URL paths only.
*
* `local-file` is the only mode. A second `artifact-api` mode (a
* Bearer-authenticated control-plane pull) existed here through v17 with
* zero consumers in any repo — the cloud runtime uses its own
* `ArtifactApiClient`, and package distribution into a running OSS
* instance goes through `@objectstack/cloud-connection` — and was removed
* when #4246 forced the declared-vs-enforced question. `start()` rejects
* an unknown mode loudly rather than silently scanning the filesystem
* instead.
*/
artifactSource?: { mode: 'local-file'; path: string; fetchTimeoutMs?: number };
/**
* Register the `sys_metadata` + `sys_metadata_history` storage objects
* on this kernel. Default `true` for backward compatibility.
*
* Set to `false` for **per-project** kernels: in cloud / project mode the
* control plane is the sole owner of metadata storage tables — exposing
* them inside each project kernel would leak control-plane schema into
* business-data namespaces.
*/
registerSystemObjects?: boolean;
/**
* Owning package id for source-file metadata loaded by the filesystem
* scanner (`watch`/eager mode) — the project's `defineStack({ manifest:
* { id } })` id. When set, scanned items are stamped with
* `_packageId`/`_provenance: 'package'` via `applyProtection`, exactly
* like the artifact path, so GET /meta consumers can tell code-defined
* metadata from user-authored rows.
*
* Leave unset when the host has no package identity — items then stay
* unstamped (runtime-authored semantics). Do NOT pass a guessed value:
* `_packageId` feeds `isArtifactBacked()` write authorization, so a
* wrong id silently changes who may edit these items.
*/
packageId?: string;
}
export class MetadataPlugin implements Plugin {
name = 'com.objectstack.metadata';
type = 'standard' as const;
version = '1.0.0';
/**
* Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the
* kernel name this plugin when a consumer requires `metadata` before it
* initializes.
*/
providesServices = ['metadata'];
/**
* init() registers the metadata system objects through the `manifest`
* service ObjectQLPlugin provides — order-if-present so that
* registration is deterministic instead of "whichever init ran first"
* (ADR-0116, #4471). Soft, not hard: without an engine the plugin
* degrades on purpose (objects are discovered via the legacy fallback).
*/
optionalDependencies = ['com.objectstack.engine.objectql'];
private manager: NodeMetadataManager;
private options: MetadataPluginOptions;
private repository?: import('@objectstack/metadata-core').MetadataRepository;
/** Chokidar watcher on the artifact file (local-file mode) — ADR-0008 PR-8. */
private artifactWatcher?: { close: () => Promise<void> };
/**
* The context handed to `init()`, retained so `destroy()` can log.
* [#10772] `Plugin.destroy()` takes NO argument — it is the kernel's only
* teardown hook — so the context the old `stop(ctx)` alias received has to
* be captured at init time instead of arriving at teardown time.
*/
private initCtx?: PluginContext;
/**
* The most recently parsed artifact metadata (the plural-field record:
* `objects`, `views`, `data`, …). Carried on the `metadata:reloaded`
* payload so runtime consumers can react to collections that never enter
* the MetadataManager — notably seeds (`data`), whose items have no
* `name` and are skipped by `_parseAndRegisterArtifact`'s register loop.
*/
private lastParsedMetadata?: Record<string, unknown[]>;
/**
* Once-per-process dedupe for the summaries the versioned artifact window
* emits. The artifact watcher replays `_parseAndRegisterArtifact` on every
* file change, so without this a dev loop over a legacy artifact would
* re-announce the same finding on every reload — the same shape
* `Protocol.storedConversionWarned` guards on the stored-row pass, which
* this surfacing is modeled on.
*
* Two key families share the set, because they share the replay:
* `<conversionId>|<label>` for a forward-conversion summary (#12772), and
* `unbound-form-predicate-root|<label>` for the unbound-root notice
* (#12915) — one line per artifact there, not one per conversion, since
* the notice already aggregates every finding it made.
*/
private artifactConversionWarned = new Set<string>();
constructor(options: MetadataPluginOptions = {}) {
// Documented default: `watch: false` (see {@link MetadataPluginOptions.watch}).
// Normalized here rather than spelled `{ watch: false, ...options }` because a
// spread preserves an explicitly-passed `watch: undefined` verbatim, and not
// every read of this flag routes through the nullish fallback below — the
// `start()`-time FileSystemRepository `disableWatch` keys on `=== false`. Coercing
// once here makes the omitted key and an explicit `undefined` resolve identically
// at EVERY downstream read.
this.options = {
...options,
watch: options.watch ?? false
};
const rootDir = this.options.rootDir || process.cwd();
// Sealed-runtime carve-out: `bootstrap: 'artifact-only'` MUST NOT touch
// the filesystem at all — that includes chokidar subscriptions. Force
// watch off in that mode regardless of `options.watch`. The other two
// modes ('eager', 'lazy') honor the user's flag; `lazy` + watch is a
// valid combination because chokidar attaches to `rootDir` directly,
// not as a side effect of any priming pass.
const bootstrapMode = this.options.config?.bootstrap ?? 'eager';
const effectiveWatch =
bootstrapMode === 'artifact-only' ? false : (this.options.watch ?? false);
this.manager = new NodeMetadataManager({
rootDir,
watch: effectiveWatch,
formats: ['yaml', 'json', 'typescript', 'javascript']
});
// Initialize with default type registry
this.manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
}
init = async (ctx: PluginContext) => {
// [#10772] Retained for `destroy()`, which the kernel calls with no
// context. Assigned before anything that can throw.
this.initCtx = ctx;
ctx.logger.info('Initializing Metadata Manager', {
root: this.options.rootDir || process.cwd(),
watch: this.options.watch,
artifactSource: this.options.artifactSource?.mode,
});
// Register Metadata Manager as the primary metadata service provider.
ctx.registerService('metadata', this.manager);
// Register metadata system objects via the manifest service (if available).
// MetadataPlugin may init before ObjectQLPlugin, so wrap in try/catch.
// Skipped when `registerSystemObjects: false` (per-project kernels in
// cloud / project mode — sys_* live exclusively in the control plane).
const registerSysObjects = this.options.registerSystemObjects !== false;
if (registerSysObjects) {
try {
const manifestService = ctx.getService<{ register(m: any): void }>('manifest');
// Register the queryable metadata-layer platform objects.
manifestService.register({
id: 'com.objectstack.metadata-objects',
name: 'Metadata Platform Objects',
version: '1.0.0',
type: 'plugin',
scope: 'system',
defaultDatasource: 'cloud',
objects: queryableMetadataObjects,
});
ctx.logger.info('Registered system metadata objects', {
queryable: queryableMetadataObjects.map((object) => object.name),
});
} catch {
// ObjectQL not loaded yet — objects will be discovered via legacy fallback
}
}
ctx.logger.info('MetadataPlugin providing metadata service (primary mode)', {
mode: this.options.artifactSource?.mode ?? 'file-system',
features: ['watch', 'multi-format', 'query', 'type-registry']
});
}
start = async (ctx: PluginContext) => {
const src = this.options.artifactSource;
const mode = this.options.config?.bootstrap ?? 'eager';
ctx.logger.info('[MetadataPlugin] Bootstrapping metadata', {
bootstrap: mode,
artifactSource: src?.mode ?? 'none',
});
// Reject a non-`local-file` source before choosing any load path. The
// union is single-member so TypeScript callers can't get here, but JS
// callers and config plumbed through `any` can — and the fall-through
// below would otherwise treat "unsupported source" as "no source"
// (eager would silently scan the filesystem instead of loading the
// artifact the caller named). The removed `artifact-api` mode gets a
// pointed migration message (#4246).
if (src && (src as { mode: string }).mode !== 'local-file') {
const bad = (src as { mode: string }).mode;
throw new Error(
`[MetadataPlugin] artifactSource.mode '${bad}' is not supported`
+ (bad === 'artifact-api'
? " — the 'artifact-api' source was removed. Load the same artifact with"
+ " { mode: 'local-file', path: '<http(s) URL>' } (e.g. the control plane's"
+ " /pub/v1/environments/:id/artifact route), or install packages into a running"
+ ' runtime via @objectstack/cloud-connection.'
: ". The only artifact source is { mode: 'local-file', path }."),
);
}
if (mode === 'artifact-only') {
// Sealed-runtime mode: ONLY load from a pre-compiled artifact. Never
// touch the filesystem. Required for Edge / serverless / read-only
// production deployments where the running process must not depend
// on local source files.
if (src) {
await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs);
} else {
throw new Error('[MetadataPlugin] bootstrap=artifact-only requires options.artifactSource to be set');
}
} else if (mode === 'lazy') {
// On-demand mode: skip the eager filesystem priming pass entirely.
// Reads go through MetadataManager.load*/list* which are backed by
// the DatabaseLoader read-through cache and any registered loaders.
// An artifact source, if present, is still honored so projects can
// pin a known set of metadata at boot without paying the FS scan.
if (src) {
await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
} else {
ctx.logger.info('[MetadataPlugin] lazy bootstrap — skipping filesystem priming; metadata loads on demand');
}
} else {
// 'eager' (default): preserve historical behavior.
if (src) {
await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true });
} else {
await this._loadFromFileSystem(ctx);
}
}
// ── ADR-0008 PR-6: attach FileSystemRepository as a supplementary
// event source so PR-7 (ObjectQL SchemaRegistry), PR-9 (Studio
// SSE hook) and future cloud consumers can subscribe to the
// same canonical event stream. No write mirroring yet.
const bootstrapMode = this.options.config?.bootstrap ?? 'eager';
if (bootstrapMode !== 'artifact-only') {
try {
const path = await import('node:path');
const { FileSystemRepository } = await import('@objectstack/metadata-fs');
const rootDir = this.options.rootDir || process.cwd();
const repoRoot = path.join(rootDir, REPO_SUBDIR);
// [#23086] The actor rule, stated rather than inherited: no
// `fsActor`. An edit seen on disk, or a file no recorded write
// produced, has no acting user here, so it records `null`
// (`PutOptions.actor`, #4556), and `source: 'fs'` names the
// origin. ⛔ Never pass a label as `fsActor`. Pinned in
// `plugin-fs-repository-actor.test.ts`.
const repo = new FileSystemRepository({
root: repoRoot,
org: this.options.organizationId ?? 'system',
disableWatch: this.options.watch === false,
});
await repo.start();
this.repository = repo;
this.manager.setRepository(repo);
ctx.logger.info('[MetadataPlugin] FileSystemRepository attached', {
repoRoot,
watch: this.options.watch !== false,
});
} catch (e: any) {
ctx.logger.warn('[MetadataPlugin] Failed to attach FileSystemRepository', {
error: e?.message,
});
}
}
// Bridge realtime service from kernel service registry to MetadataManager.
try {
const realtimeService = ctx.getService('realtime');
if (realtimeService && typeof realtimeService === 'object' && 'publish' in realtimeService) {
ctx.logger.info('[MetadataPlugin] Bridging realtime service to MetadataManager for event publishing');
this.manager.setRealtimeService(realtimeService as any);
}
} catch (e: any) {
ctx.logger.debug('[MetadataPlugin] No realtime service found — metadata events will not be published', {
error: e.message,
});
}
// Register the HMR SSE endpoint when an HTTP server is available
// that exposes a raw Hono app. The endpoint is registered regardless
// of the `watch` option:
// - In `watch: true` mode the FS watcher feeds events into the hub.
// - In `watch: false` mode (e.g. artifact-mode `os dev`), an
// external watch-recompile pipeline POSTs to the same endpoint
// after rebuilding the artifact, and we reload it here before
// broadcasting.
// [commit f4e7ae5c7] PRODUCTION BOOTS DO NOT GET THIS DOOR, and that is now
// enforced rather than predicted. This comment used to read
// "production deployments simply won't have a CLI POSTing to this
// endpoint and won't surface the route to clients" — a claim about who
// is on the network, which is exactly the shape #9391 closed for
// `datasource-admin`. It was also false in the half that mattered: the
// official image runs `os start` under `NODE_ENV=production`
// (`docker/Dockerfile`), that boot reaches `createStandaloneStack`,
// and the stack composes this plugin UNCONDITIONALLY
// (`packages/runtime/src/standalone-stack.ts` gates only `artifactWatch`
// on NODE_ENV) onto a kernel that registers `HonoServerPlugin` whenever
// it serves. Both routes were therefore mounted, unauthenticated, on a
// production-shaped boot. The gate lives in the registrar — see
// `isDevMetadataEndpointEnabled` in `routes/hmr-routes.ts` — so the
// decision cannot be bypassed by a second caller; here we only handle
// its answer.
try {
// [#4251] Both names are the SAME instance; `http.server` is the
// canonical one (the only name every provider registers), read
// first. Per-name try — `getService` THROWS for an empty slot, so
// a single try around `canonical ?? alias` never reaches the
// alias: exactly the shape this read had before (alias-first),
// whose fallback therefore never once fired. `getRawApp?()` is on
// the contract now — the deliberate framework-handle escape,
// declared once there instead of per consumer. The alias fallback
// dies with the alias registrations.
const readServer = (name: string): IHttpServer | undefined => {
try { return ctx.getService<IHttpServer>(name); } catch { return undefined; }
};
const httpServer = readServer('http.server') ?? readServer('http-server');