@objectstack/metadata 17.2.0 → 17.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1967 -0
- package/README.md +6 -4
- package/dist/errors.cjs +1 -85
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.d.cts +1 -22
- package/dist/errors.d.ts +1 -22
- package/dist/errors.js +2 -84
- package/dist/errors.js.map +1 -1
- package/dist/index.cjs +1014 -302
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +496 -29
- package/dist/index.d.ts +496 -29
- package/dist/index.js +1013 -299
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.cjs +175 -67
- package/dist/migrations/index.cjs.map +1 -1
- package/dist/migrations/index.d.cts +97 -15
- package/dist/migrations/index.d.ts +97 -15
- package/dist/migrations/index.js +178 -67
- package/dist/migrations/index.js.map +1 -1
- package/dist/node.cjs +1014 -302
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +156 -3
- package/dist/node.d.ts +156 -3
- package/dist/node.js +1013 -299
- package/dist/node.js.map +1 -1
- package/dist/view-container.cjs +37 -0
- package/dist/view-container.cjs.map +1 -0
- package/dist/view-container.d.cts +76 -0
- package/dist/view-container.d.ts +76 -0
- package/dist/view-container.js +12 -0
- package/dist/view-container.js.map +1 -0
- package/package.json +52 -21
package/dist/node.js
CHANGED
|
@@ -16,9 +16,14 @@ var __export = (target, all) => {
|
|
|
16
16
|
// src/routes/hmr-routes.ts
|
|
17
17
|
var hmr_routes_exports = {};
|
|
18
18
|
__export(hmr_routes_exports, {
|
|
19
|
+
isDevMetadataEndpointEnabled: () => isDevMetadataEndpointEnabled,
|
|
19
20
|
registerMetadataHmrRoutes: () => registerMetadataHmrRoutes
|
|
20
21
|
});
|
|
22
|
+
function isDevMetadataEndpointEnabled(env = process.env) {
|
|
23
|
+
return (env.NODE_ENV ?? "").trim().toLowerCase() === "development";
|
|
24
|
+
}
|
|
21
25
|
function registerMetadataHmrRoutes(app, manager, options = {}) {
|
|
26
|
+
if (!isDevMetadataEndpointEnabled()) return null;
|
|
22
27
|
const routePath = options.path ?? "/api/v1/dev/metadata-events";
|
|
23
28
|
const listeners = /* @__PURE__ */ new Set();
|
|
24
29
|
const broadcast = (evt) => {
|
|
@@ -351,7 +356,7 @@ export default metadata;
|
|
|
351
356
|
};
|
|
352
357
|
|
|
353
358
|
// src/loaders/database-loader.ts
|
|
354
|
-
import { SysMetadataObject, SysMetadataHistoryObject } from "@objectstack/metadata-core";
|
|
359
|
+
import { SysMetadataObject as SysMetadataObject2, SysMetadataHistoryObject as SysMetadataHistoryObject2 } from "@objectstack/metadata-core";
|
|
355
360
|
import { applyConversionsToStoredItem } from "@objectstack/spec";
|
|
356
361
|
import { PLURAL_TO_SINGULAR } from "@objectstack/spec/shared";
|
|
357
362
|
|
|
@@ -519,141 +524,49 @@ var LRUCache = class {
|
|
|
519
524
|
}
|
|
520
525
|
};
|
|
521
526
|
|
|
522
|
-
// src/
|
|
523
|
-
import {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
"42710",
|
|
532
|
-
// duplicate_object — index / constraint already exists
|
|
533
|
-
// MySQL / MariaDB (mysql2 puts the symbolic name on `code`)
|
|
534
|
-
"ER_TABLE_EXISTS_ERROR",
|
|
535
|
-
// 1050
|
|
536
|
-
"ER_DUP_FIELDNAME",
|
|
537
|
-
// 1060
|
|
538
|
-
"ER_DUP_KEYNAME"
|
|
539
|
-
// 1061
|
|
540
|
-
]),
|
|
541
|
-
errnos: /* @__PURE__ */ new Set([1050, 1060, 1061]),
|
|
542
|
-
/**
|
|
543
|
-
* Message fallback for drivers that carry no machine-readable code —
|
|
544
|
-
* notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for
|
|
545
|
-
* every DDL failure, so the message is the only signal available:
|
|
546
|
-
* - `table sys_metadata already exists`
|
|
547
|
-
* - `duplicate column name: environment_id`
|
|
548
|
-
* - `index idx_x already exists`
|
|
549
|
-
* Postgres phrases its own as `relation "x" already exists` /
|
|
550
|
-
* `column "x" of relation "y" already exists`, which matches the same test.
|
|
551
|
-
*/
|
|
552
|
-
message: /already exists|duplicate column name|duplicate key name/i
|
|
553
|
-
};
|
|
554
|
-
var MISSING_TABLE = {
|
|
555
|
-
codes: /* @__PURE__ */ new Set([
|
|
556
|
-
"42P01",
|
|
557
|
-
// PostgreSQL undefined_table
|
|
558
|
-
"ER_NO_SUCH_TABLE"
|
|
559
|
-
// MySQL / MariaDB 1146
|
|
560
|
-
]),
|
|
561
|
-
errnos: /* @__PURE__ */ new Set([1146]),
|
|
562
|
-
/**
|
|
563
|
-
* - SQLite / libsql: `no such table: sys_metadata_history`
|
|
564
|
-
* - PostgreSQL: `relation "sys_metadata_history" does not exist`
|
|
565
|
-
* - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
|
|
566
|
-
*/
|
|
567
|
-
message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
|
|
568
|
-
excludes: {
|
|
569
|
-
/**
|
|
570
|
-
* Exactly the three SQLSTATEs the docblock above already names as
|
|
571
|
-
* must-stay-loud neighbours of `does not exist`. They are listed here
|
|
572
|
-
* rather than merely trusted to miss the message test, because two of
|
|
573
|
-
* them (42703 columns, 42704 constraints/triggers) have a phrasing that
|
|
574
|
-
* *does* hit it, and because a code is a fact where prose is a guess.
|
|
575
|
-
*
|
|
576
|
-
* Postgres-shaped on purpose: measured, neither MySQL
|
|
577
|
-
* (`Unknown column 'label' in 'field list'`) nor SQLite
|
|
578
|
-
* (`no such column: bogus`, `table t has no column named label`)
|
|
579
|
-
* phrases a sub-object failure so that a missing-table phrase falls out
|
|
580
|
-
* of it, so there is nothing there to exclude. Adding their codes would
|
|
581
|
-
* be surface with no defect behind it.
|
|
582
|
-
*/
|
|
583
|
-
codes: /* @__PURE__ */ new Set([
|
|
584
|
-
"42703",
|
|
585
|
-
// undefined_column
|
|
586
|
-
"42704",
|
|
587
|
-
// undefined_object — constraint, trigger, role, type, …
|
|
588
|
-
"3D000"
|
|
589
|
-
// invalid_catalog_name — `database "x" does not exist`
|
|
590
|
-
]),
|
|
591
|
-
/**
|
|
592
|
-
* `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
|
|
593
|
-
* failure about something *inside* a relation, which therefore says the
|
|
594
|
-
* relation itself is present. The two in-repo siblings that carry this
|
|
595
|
-
* phrase are `mapDataError` (`packages/rest`, #5352) and
|
|
596
|
-
* `service-analytics`'s missing-column subtraction (#6035/PR #6346).
|
|
597
|
-
*
|
|
598
|
-
* [#6615] All three now read one home — `@objectstack/types` — instead
|
|
599
|
-
* of three hand-kept copies, so the phrase can no longer be taught to
|
|
600
|
-
* the repo a fourth time or drift in one package only. The **width**
|
|
601
|
-
* difference that used to justify the copy is preserved and is the
|
|
602
|
-
* reason the home exports two functions rather than one: those two
|
|
603
|
-
* *extract* the column name to phrase a better error, so a miss costs a
|
|
604
|
-
* vaguer message; this one *excludes*, so a miss restores the
|
|
605
|
-
* corruption. {@link isRelationSubObjectPhrase} is therefore the wider
|
|
606
|
-
* question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
|
|
607
|
-
* anchors: any sub-object, any quoted identifier, any verdict.
|
|
608
|
-
* Over-matching here only ever converts a benign verdict into a loud
|
|
609
|
-
* one, which is the direction this whole module already errs in.
|
|
610
|
-
*/
|
|
611
|
-
matchesMessage: isRelationSubObjectPhrase
|
|
527
|
+
// src/loaders/database-loader.ts
|
|
528
|
+
import { isMissingTableError, isSchemaAlreadyExistsError } from "@objectstack/types";
|
|
529
|
+
|
|
530
|
+
// src/migrations/driver-exec.ts
|
|
531
|
+
function resolveDriverExec(driver) {
|
|
532
|
+
const candidate = driver;
|
|
533
|
+
if (!candidate) return void 0;
|
|
534
|
+
if (typeof candidate.execute === "function") {
|
|
535
|
+
return (sql, bindings) => candidate.execute(sql, bindings ? [...bindings] : []);
|
|
612
536
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (typeof error === "string") {
|
|
618
|
-
if (signature.excludes?.matchesMessage(error)) return false;
|
|
619
|
-
return signature.message.test(error);
|
|
620
|
-
}
|
|
621
|
-
if (typeof error !== "object") return false;
|
|
622
|
-
const err = error;
|
|
623
|
-
const excludes = signature.excludes;
|
|
624
|
-
if (excludes) {
|
|
625
|
-
if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
|
|
626
|
-
if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
|
|
627
|
-
}
|
|
628
|
-
if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
|
|
629
|
-
if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
|
|
630
|
-
if (typeof err.message === "string" && signature.message.test(err.message)) return true;
|
|
631
|
-
return matchesDriverError(err.cause, signature, depth + 1);
|
|
632
|
-
}
|
|
633
|
-
function isSchemaAlreadyExistsError(error, depth = 0) {
|
|
634
|
-
return matchesDriverError(error, ALREADY_EXISTS, depth);
|
|
537
|
+
if (typeof candidate.raw === "function") {
|
|
538
|
+
return (sql, bindings) => candidate.raw(sql, bindings ? [...bindings] : []);
|
|
539
|
+
}
|
|
540
|
+
return void 0;
|
|
635
541
|
}
|
|
636
|
-
function
|
|
637
|
-
return
|
|
542
|
+
function driverExecRefusal(helper) {
|
|
543
|
+
return `${helper}: driver must expose an .execute(sql, bindings?) or .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) exposes .execute(), as does its SqliteWasmDriver subclass; cloud-side TursoDriver also conforms.`;
|
|
638
544
|
}
|
|
639
545
|
|
|
640
546
|
// src/migrations/migrate-project-id-to-environment-id.ts
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
];
|
|
547
|
+
import { SysMetadataObject, SysMetadataHistoryObject } from "@objectstack/metadata-core";
|
|
548
|
+
var SOURCE_COLUMN = "project_id";
|
|
549
|
+
var TARGET_COLUMN = "environment_id";
|
|
550
|
+
var CANDIDATE_OBJECTS = [SysMetadataObject, SysMetadataHistoryObject];
|
|
551
|
+
function declaresColumn(object, column) {
|
|
552
|
+
return Object.prototype.hasOwnProperty.call(object.fields ?? {}, column);
|
|
553
|
+
}
|
|
554
|
+
var CANDIDATE_TABLES = CANDIDATE_OBJECTS.map((o) => o.name);
|
|
555
|
+
var AFFECTED_TABLES = CANDIDATE_OBJECTS.filter((o) => declaresColumn(o, TARGET_COLUMN)).map((o) => o.name);
|
|
645
556
|
async function migrateProjectIdToEnvironmentId(driver) {
|
|
646
|
-
const
|
|
647
|
-
if (
|
|
648
|
-
throw new Error(
|
|
649
|
-
"migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms."
|
|
650
|
-
);
|
|
557
|
+
const exec = resolveDriverExec(driver);
|
|
558
|
+
if (!exec) {
|
|
559
|
+
throw new Error(driverExecRefusal("migrateProjectIdToEnvironmentId"));
|
|
651
560
|
}
|
|
652
561
|
const results = [];
|
|
653
|
-
for (const table of
|
|
562
|
+
for (const table of CANDIDATE_TABLES) {
|
|
563
|
+
if (!AFFECTED_TABLES.includes(table)) {
|
|
564
|
+
results.push({ table, status: "skipped_not_declared" });
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
654
567
|
try {
|
|
655
|
-
const hasColumn = await _columnExists(
|
|
656
|
-
const alreadyMigrated = await _columnExists(
|
|
568
|
+
const hasColumn = await _columnExists(exec, table, SOURCE_COLUMN);
|
|
569
|
+
const alreadyMigrated = await _columnExists(exec, table, TARGET_COLUMN);
|
|
657
570
|
if (alreadyMigrated && !hasColumn) {
|
|
658
571
|
results.push({ table, status: "already_done" });
|
|
659
572
|
continue;
|
|
@@ -662,8 +575,8 @@ async function migrateProjectIdToEnvironmentId(driver) {
|
|
|
662
575
|
results.push({ table, status: "table_missing" });
|
|
663
576
|
continue;
|
|
664
577
|
}
|
|
665
|
-
await
|
|
666
|
-
`ALTER TABLE "${table}" RENAME COLUMN
|
|
578
|
+
await exec(
|
|
579
|
+
`ALTER TABLE "${table}" RENAME COLUMN ${SOURCE_COLUMN} TO ${TARGET_COLUMN}`
|
|
667
580
|
);
|
|
668
581
|
results.push({ table, status: "renamed" });
|
|
669
582
|
} catch (err) {
|
|
@@ -672,14 +585,14 @@ async function migrateProjectIdToEnvironmentId(driver) {
|
|
|
672
585
|
}
|
|
673
586
|
return results;
|
|
674
587
|
}
|
|
675
|
-
async function _columnExists(
|
|
588
|
+
async function _columnExists(exec, table, column) {
|
|
676
589
|
try {
|
|
677
|
-
const rows = await
|
|
590
|
+
const rows = await exec(`PRAGMA table_info("${table}")`);
|
|
678
591
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
679
592
|
const list2 = Array.isArray(rows[0]) ? rows[0] : rows;
|
|
680
593
|
return list2.some((r) => r?.name === column);
|
|
681
594
|
}
|
|
682
|
-
const result = await
|
|
595
|
+
const result = await exec(
|
|
683
596
|
`SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
|
|
684
597
|
[table, column]
|
|
685
598
|
);
|
|
@@ -691,6 +604,16 @@ async function _columnExists(driver, table, column) {
|
|
|
691
604
|
}
|
|
692
605
|
|
|
693
606
|
// src/loaders/database-loader.ts
|
|
607
|
+
function canonicalIsoInstant(value) {
|
|
608
|
+
if (value === null || value === void 0) return void 0;
|
|
609
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value.toISOString();
|
|
610
|
+
if (typeof value === "string") return value;
|
|
611
|
+
return String(value);
|
|
612
|
+
}
|
|
613
|
+
function isoFromValidDate(value) {
|
|
614
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
615
|
+
return value;
|
|
616
|
+
}
|
|
694
617
|
var DatabaseLoader = class {
|
|
695
618
|
constructor(options) {
|
|
696
619
|
this.contract = {
|
|
@@ -739,7 +662,7 @@ var DatabaseLoader = class {
|
|
|
739
662
|
if (cacheEnabled) {
|
|
740
663
|
const lruOpts = {
|
|
741
664
|
maxSize: cacheOpts?.maxSize ?? 500,
|
|
742
|
-
ttl: cacheOpts?.
|
|
665
|
+
ttl: cacheOpts?.ttlMs ?? 6e4
|
|
743
666
|
};
|
|
744
667
|
this.loadCache = new LRUCache(lruOpts);
|
|
745
668
|
this.loadManyCache = new LRUCache(lruOpts);
|
|
@@ -827,9 +750,11 @@ var DatabaseLoader = class {
|
|
|
827
750
|
}
|
|
828
751
|
return this.driver.create(table, data);
|
|
829
752
|
}
|
|
753
|
+
// `null` is the driver path's not-found answer (`IDataDriver.update()`,
|
|
754
|
+
// #13878); both callers here resolve the row first and discard the result.
|
|
830
755
|
async _update(table, id, data) {
|
|
831
756
|
if (this.engine) {
|
|
832
|
-
return this.engine.update(table, {
|
|
757
|
+
return this.engine.update(table, { ...data, id });
|
|
833
758
|
}
|
|
834
759
|
return this.driver.update(table, id, data);
|
|
835
760
|
}
|
|
@@ -874,7 +799,7 @@ var DatabaseLoader = class {
|
|
|
874
799
|
}
|
|
875
800
|
return max + 1;
|
|
876
801
|
} catch (error) {
|
|
877
|
-
if (isMissingTableError(error)) return 1;
|
|
802
|
+
if (isMissingTableError(error, this.historyTableName)) return 1;
|
|
878
803
|
throw error;
|
|
879
804
|
}
|
|
880
805
|
}
|
|
@@ -912,7 +837,7 @@ var DatabaseLoader = class {
|
|
|
912
837
|
}
|
|
913
838
|
try {
|
|
914
839
|
await this.driver.syncSchema(this.tableName, {
|
|
915
|
-
...
|
|
840
|
+
...SysMetadataObject2,
|
|
916
841
|
name: this.tableName
|
|
917
842
|
});
|
|
918
843
|
} catch (error) {
|
|
@@ -951,7 +876,7 @@ var DatabaseLoader = class {
|
|
|
951
876
|
}
|
|
952
877
|
try {
|
|
953
878
|
await this.driver.syncSchema(this.historyTableName, {
|
|
954
|
-
...
|
|
879
|
+
...SysMetadataHistoryObject2,
|
|
955
880
|
name: this.historyTableName
|
|
956
881
|
});
|
|
957
882
|
if (this.historySchemaFailureReported) {
|
|
@@ -1120,9 +1045,9 @@ var DatabaseLoader = class {
|
|
|
1120
1045
|
source: row.source,
|
|
1121
1046
|
tags: row.tags ? typeof row.tags === "string" ? JSON.parse(row.tags) : row.tags : void 0,
|
|
1122
1047
|
createdBy: row.created_by,
|
|
1123
|
-
createdAt: row.created_at,
|
|
1048
|
+
createdAt: isoFromValidDate(row.created_at),
|
|
1124
1049
|
updatedBy: row.updated_by,
|
|
1125
|
-
updatedAt: row.updated_at
|
|
1050
|
+
updatedAt: isoFromValidDate(row.updated_at)
|
|
1126
1051
|
};
|
|
1127
1052
|
}
|
|
1128
1053
|
// ==========================================
|
|
@@ -1173,7 +1098,7 @@ var DatabaseLoader = class {
|
|
|
1173
1098
|
* with its empty value.
|
|
1174
1099
|
*/
|
|
1175
1100
|
rethrowUnlessTableUnprovisioned(error) {
|
|
1176
|
-
if (isMissingTableError(error)) return;
|
|
1101
|
+
if (isMissingTableError(error, this.tableName)) return;
|
|
1177
1102
|
throw error;
|
|
1178
1103
|
}
|
|
1179
1104
|
// ==========================================
|
|
@@ -1223,17 +1148,38 @@ var DatabaseLoader = class {
|
|
|
1223
1148
|
};
|
|
1224
1149
|
}
|
|
1225
1150
|
}
|
|
1226
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* The one type-wide read both plural readers share: every row of `type`, each
|
|
1153
|
+
* body paired with the `name` COLUMN it was stored under.
|
|
1154
|
+
*
|
|
1155
|
+
* [#14205] `name` is `null` only for a row whose key column does not hold a
|
|
1156
|
+
* string. Such a row is still a body {@link loadMany} must return — dropping
|
|
1157
|
+
* it would change what consumers see today — but it has no usable identity,
|
|
1158
|
+
* so {@link loadManyKeyed} filters it out rather than invent one.
|
|
1159
|
+
*
|
|
1160
|
+
* One query and one cache entry serve both methods: `loadMany()` used to own
|
|
1161
|
+
* them, and splitting them would have made every keyed `list()` read miss the
|
|
1162
|
+
* cache and re-hit the database.
|
|
1163
|
+
*/
|
|
1164
|
+
async readTypeRows(type) {
|
|
1227
1165
|
await this.ensureSchema();
|
|
1228
1166
|
if (this.loadManyCache) {
|
|
1229
1167
|
const cached = this.loadManyCache.get(type);
|
|
1230
|
-
if (cached !== void 0)
|
|
1168
|
+
if (cached !== void 0) {
|
|
1169
|
+
return cached;
|
|
1170
|
+
}
|
|
1231
1171
|
}
|
|
1232
1172
|
try {
|
|
1233
1173
|
const rows = await this._find(this.tableName, {
|
|
1234
1174
|
where: this.baseFilter(type)
|
|
1235
1175
|
});
|
|
1236
|
-
const result =
|
|
1176
|
+
const result = [];
|
|
1177
|
+
for (const row of rows) {
|
|
1178
|
+
const data = this.rowToData(row);
|
|
1179
|
+
if (data === null) continue;
|
|
1180
|
+
const name = row.name;
|
|
1181
|
+
result.push({ name: typeof name === "string" && name !== "" ? name : null, data });
|
|
1182
|
+
}
|
|
1237
1183
|
this.loadManyCache?.set(type, result);
|
|
1238
1184
|
return result;
|
|
1239
1185
|
} catch (error) {
|
|
@@ -1241,6 +1187,29 @@ var DatabaseLoader = class {
|
|
|
1241
1187
|
return [];
|
|
1242
1188
|
}
|
|
1243
1189
|
}
|
|
1190
|
+
async loadMany(type, _options) {
|
|
1191
|
+
return (await this.readTypeRows(type)).map((entry) => entry.data);
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* [#14205] The keyed half of {@link loadMany} — see
|
|
1195
|
+
* {@link MetadataKeyedItem} for why the row key travels beside the body
|
|
1196
|
+
* instead of inside it.
|
|
1197
|
+
*
|
|
1198
|
+
* `DatabaseLoader` is where the defect was measured: an aggregated view
|
|
1199
|
+
* container is written by `register('view', OBJECT, container)` and stored
|
|
1200
|
+
* verbatim, so its `sys_metadata` row carries the identity in the `name`
|
|
1201
|
+
* COLUMN and the body has none. {@link rowToData} returns that body without
|
|
1202
|
+
* folding the column in — deliberately, and unchanged here.
|
|
1203
|
+
*/
|
|
1204
|
+
async loadManyKeyed(type, _options) {
|
|
1205
|
+
const entries = await this.readTypeRows(type);
|
|
1206
|
+
const keyed = [];
|
|
1207
|
+
for (const entry of entries) {
|
|
1208
|
+
if (entry.name === null) continue;
|
|
1209
|
+
keyed.push({ name: entry.name, data: entry.data });
|
|
1210
|
+
}
|
|
1211
|
+
return keyed;
|
|
1212
|
+
}
|
|
1244
1213
|
async exists(type, name) {
|
|
1245
1214
|
await this.ensureSchema();
|
|
1246
1215
|
if (this.loadCache) {
|
|
@@ -1276,7 +1245,7 @@ var DatabaseLoader = class {
|
|
|
1276
1245
|
const metadataStr = typeof row.metadata === "string" ? row.metadata : JSON.stringify(row.metadata);
|
|
1277
1246
|
const stats = {
|
|
1278
1247
|
size: metadataStr.length,
|
|
1279
|
-
mtime: record.updatedAt ?? record.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1248
|
+
mtime: canonicalIsoInstant(record.updatedAt ?? record.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1280
1249
|
format: "json",
|
|
1281
1250
|
etag: record.checksum
|
|
1282
1251
|
};
|
|
@@ -1337,7 +1306,7 @@ var DatabaseLoader = class {
|
|
|
1337
1306
|
changeNote: row.change_note,
|
|
1338
1307
|
organizationId: row.organization_id,
|
|
1339
1308
|
recordedBy: row.recorded_by,
|
|
1340
|
-
recordedAt: row.recorded_at
|
|
1309
|
+
recordedAt: isoFromValidDate(row.recorded_at)
|
|
1341
1310
|
};
|
|
1342
1311
|
}
|
|
1343
1312
|
/**
|
|
@@ -1394,7 +1363,7 @@ var DatabaseLoader = class {
|
|
|
1394
1363
|
changeNote: row.change_note,
|
|
1395
1364
|
organizationId: row.organization_id,
|
|
1396
1365
|
recordedBy: row.recorded_by,
|
|
1397
|
-
recordedAt: row.recorded_at
|
|
1366
|
+
recordedAt: isoFromValidDate(row.recorded_at)
|
|
1398
1367
|
};
|
|
1399
1368
|
});
|
|
1400
1369
|
return { records: result, total, hasMore };
|
|
@@ -1543,6 +1512,33 @@ function generateId() {
|
|
|
1543
1512
|
return `meta_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
|
1544
1513
|
}
|
|
1545
1514
|
|
|
1515
|
+
// src/loaders/ambiguous-metadata-stem.ts
|
|
1516
|
+
var AMBIGUOUS_METADATA_STEM_CODE = "AMBIGUOUS_METADATA_STEM";
|
|
1517
|
+
var AMBIGUOUS_METADATA_STEM_STATUS = 500;
|
|
1518
|
+
var AMBIGUOUS_METADATA_STEM_BRAND = /* @__PURE__ */ Symbol.for("objectstack.metadata.ambiguousStem");
|
|
1519
|
+
var _a, _b;
|
|
1520
|
+
var AmbiguousMetadataStemError = class extends (_b = Error, _a = AMBIGUOUS_METADATA_STEM_BRAND, _b) {
|
|
1521
|
+
constructor(type, stem, paths) {
|
|
1522
|
+
const sorted = [...paths].sort();
|
|
1523
|
+
super(
|
|
1524
|
+
`Ambiguous metadata name \`${stem}\` for type \`${type}\`: ${sorted.length} files resolve to the same name \u2014 ${sorted.map((p) => `\`${p}\``).join(", ")}. Only the first would ever be served (extension precedence: .json, .yaml, .yml, .ts, .js), so the others are listed and unreachable. Delete or rename all but one.`
|
|
1525
|
+
);
|
|
1526
|
+
/** Brand — see the module doc on why this is not `instanceof`. */
|
|
1527
|
+
this[_a] = true;
|
|
1528
|
+
/** ADR-0112 wire code. */
|
|
1529
|
+
this.code = AMBIGUOUS_METADATA_STEM_CODE;
|
|
1530
|
+
/** HTTP status a transport should answer. */
|
|
1531
|
+
this.status = AMBIGUOUS_METADATA_STEM_STATUS;
|
|
1532
|
+
this.name = "AmbiguousMetadataStemError";
|
|
1533
|
+
this.type = type;
|
|
1534
|
+
this.stem = stem;
|
|
1535
|
+
this.paths = sorted;
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
function isAmbiguousMetadataStemError(err) {
|
|
1539
|
+
return typeof err === "object" && err !== null && err[AMBIGUOUS_METADATA_STEM_BRAND] === true;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1546
1542
|
// src/endpoint-matcher.ts
|
|
1547
1543
|
import {
|
|
1548
1544
|
ApiEndpointSchema,
|
|
@@ -1694,6 +1690,39 @@ var EndpointMatcher = class {
|
|
|
1694
1690
|
}
|
|
1695
1691
|
};
|
|
1696
1692
|
|
|
1693
|
+
// src/view-container-expansion.ts
|
|
1694
|
+
import {
|
|
1695
|
+
expandViewContainer,
|
|
1696
|
+
isAggregatedViewContainer
|
|
1697
|
+
} from "@objectstack/spec";
|
|
1698
|
+
import { applyProtection } from "@objectstack/spec/shared";
|
|
1699
|
+
|
|
1700
|
+
// src/view-container.ts
|
|
1701
|
+
function deriveViewContainerObject(container) {
|
|
1702
|
+
if (!container || typeof container !== "object") return void 0;
|
|
1703
|
+
const c = container;
|
|
1704
|
+
const own = typeof c.object === "string" && c.object ? c.object : void 0;
|
|
1705
|
+
const byName = typeof c.name === "string" && c.name ? c.name : void 0;
|
|
1706
|
+
return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// src/view-container-expansion.ts
|
|
1710
|
+
function expandRuntimeViewContainer(data) {
|
|
1711
|
+
if (!isAggregatedViewContainer(data)) return [];
|
|
1712
|
+
const container = data;
|
|
1713
|
+
const viewObject = deriveViewContainerObject(container);
|
|
1714
|
+
if (!viewObject) return [];
|
|
1715
|
+
const out = [];
|
|
1716
|
+
for (const vi of expandViewContainer(viewObject, container)) {
|
|
1717
|
+
applyProtection(vi, {
|
|
1718
|
+
packageId: container._packageId,
|
|
1719
|
+
packageVersion: container._packageVersion
|
|
1720
|
+
});
|
|
1721
|
+
out.push(vi);
|
|
1722
|
+
}
|
|
1723
|
+
return out;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1697
1726
|
// src/metadata-manager.ts
|
|
1698
1727
|
var WRITABLE_LOADER_METHODS = ["save", "delete"];
|
|
1699
1728
|
var WRITABLE_LOADER_METHOD_SIGNATURE = {
|
|
@@ -1733,8 +1762,6 @@ var _MetadataManager = class _MetadataManager {
|
|
|
1733
1762
|
this.watchCallbacks = /* @__PURE__ */ new Map();
|
|
1734
1763
|
// In-memory metadata registry: type -> name -> data
|
|
1735
1764
|
this.registry = /* @__PURE__ */ new Map();
|
|
1736
|
-
// Overlay storage: "type:name:scope" -> MetadataOverlay
|
|
1737
|
-
this.overlays = /* @__PURE__ */ new Map();
|
|
1738
1765
|
// Type registry for metadata type info
|
|
1739
1766
|
this.typeRegistry = [];
|
|
1740
1767
|
// Dependency tracking: "type:name" -> dependencies
|
|
@@ -2309,6 +2336,67 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2309
2336
|
* result may be memoized depends on what happened to the read's registration
|
|
2310
2337
|
* while it ran, which only `list()` can see.
|
|
2311
2338
|
*/
|
|
2339
|
+
/**
|
|
2340
|
+
* Merge one loader's answer for `type` into `items`, under the identity that
|
|
2341
|
+
* loader holds each item by.
|
|
2342
|
+
*
|
|
2343
|
+
* ## [#14205] The identity of a loader-held item is its ROW KEY
|
|
2344
|
+
*
|
|
2345
|
+
* Both plural readers used to key a loader's items by `body.name`, and admit
|
|
2346
|
+
* an item only when the body carried a string one:
|
|
2347
|
+
*
|
|
2348
|
+
* ```ts
|
|
2349
|
+
* if (itemAny && typeof itemAny.name === 'string' && !items.has(itemAny.name))
|
|
2350
|
+
* ```
|
|
2351
|
+
*
|
|
2352
|
+
* A body is not required to name itself. `register(type, name, data)` takes
|
|
2353
|
+
* the key as its ARGUMENT, and `assertMetadataRegisterContract` says in as
|
|
2354
|
+
* many words that "A document with NO `name` of its own is fine — the argument
|
|
2355
|
+
* is the key". An aggregated `defineView` container is exactly that: no own
|
|
2356
|
+
* `name` by design, identity carried in the row's `name` column.
|
|
2357
|
+
*
|
|
2358
|
+
* So the old gate dropped every such item the moment the registry went cold
|
|
2359
|
+
* and only the loader could answer — a persisted view container vanished from
|
|
2360
|
+
* `list('view')` after a restart, and `listDiagnosed()` called the short
|
|
2361
|
+
* answer complete because no loader had thrown. Same gate, same effect, in
|
|
2362
|
+
* `listForIndex()`: a nameless `api` row fell out of the endpoint index, where
|
|
2363
|
+
* a miss reads as "nothing declares this route".
|
|
2364
|
+
*
|
|
2365
|
+
* The repair is to ask the loader for the key instead of guessing it from the
|
|
2366
|
+
* body ({@link MetadataLoader.loadManyKeyed}), and to keep the key BESIDE the
|
|
2367
|
+
* body: nothing is written into a body that deliberately has none, so the
|
|
2368
|
+
* register contract's refusal of a disagreeing `data.name` still means what it
|
|
2369
|
+
* says.
|
|
2370
|
+
*
|
|
2371
|
+
* Nothing consumers see today changes shape. For any item that went through
|
|
2372
|
+
* `register()`, a `data.name` that exists is required to EQUAL the key, so the
|
|
2373
|
+
* keyed merge produces the identical map entry; what is new is only the
|
|
2374
|
+
* entries the old gate refused. The `loadMany()` fallback below is the
|
|
2375
|
+
* pre-#14205 behaviour verbatim, for loaders that cannot produce keys
|
|
2376
|
+
* (`RemoteLoader`'s wire format carries bodies only).
|
|
2377
|
+
*
|
|
2378
|
+
* Read failures are NOT caught here: `readListUncached` warns-and-continues,
|
|
2379
|
+
* `listForIndex` deliberately throws, and that difference is each caller's to
|
|
2380
|
+
* keep.
|
|
2381
|
+
*/
|
|
2382
|
+
async admitLoaderItems(loader, type, items) {
|
|
2383
|
+
if (typeof loader.loadManyKeyed === "function") {
|
|
2384
|
+
const keyed = await loader.loadManyKeyed(type);
|
|
2385
|
+
for (const entry of keyed) {
|
|
2386
|
+
if (!entry || typeof entry.name !== "string" || entry.name === "") continue;
|
|
2387
|
+
if (items.has(entry.name)) continue;
|
|
2388
|
+
items.set(entry.name, entry.data);
|
|
2389
|
+
}
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
const loaderItems = await loader.loadMany(type);
|
|
2393
|
+
for (const item of loaderItems) {
|
|
2394
|
+
const itemAny = item;
|
|
2395
|
+
if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
|
|
2396
|
+
items.set(itemAny.name, item);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2312
2400
|
async readListUncached(type) {
|
|
2313
2401
|
const items = /* @__PURE__ */ new Map();
|
|
2314
2402
|
const typeStore = this.registry.get(type);
|
|
@@ -2321,15 +2409,12 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2321
2409
|
const errors = [];
|
|
2322
2410
|
for (const loader of this.loaders.values()) {
|
|
2323
2411
|
try {
|
|
2324
|
-
|
|
2325
|
-
for (const item of loaderItems) {
|
|
2326
|
-
const itemAny = item;
|
|
2327
|
-
if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
|
|
2328
|
-
items.set(itemAny.name, item);
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2412
|
+
await this.admitLoaderItems(loader, type, items);
|
|
2331
2413
|
this.reportLoaderReadRecovered(loader.contract.name);
|
|
2332
2414
|
} catch (e) {
|
|
2415
|
+
if (isAmbiguousMetadataStemError(e)) {
|
|
2416
|
+
throw e;
|
|
2417
|
+
}
|
|
2333
2418
|
degraded = true;
|
|
2334
2419
|
errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
2335
2420
|
this.reportLoaderReadFailure(loader.contract.name, type, e);
|
|
@@ -2480,13 +2565,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2480
2565
|
}
|
|
2481
2566
|
}
|
|
2482
2567
|
for (const loader of this.loaders.values()) {
|
|
2483
|
-
|
|
2484
|
-
for (const item of loaderItems) {
|
|
2485
|
-
const itemAny = item;
|
|
2486
|
-
if (itemAny && typeof itemAny.name === "string" && !items.has(itemAny.name)) {
|
|
2487
|
-
items.set(itemAny.name, item);
|
|
2488
|
-
}
|
|
2489
|
-
}
|
|
2568
|
+
await this.admitLoaderItems(loader, type, items);
|
|
2490
2569
|
}
|
|
2491
2570
|
return Array.from(items.values());
|
|
2492
2571
|
}
|
|
@@ -2673,6 +2752,30 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2673
2752
|
}
|
|
2674
2753
|
/**
|
|
2675
2754
|
* List all names of metadata items of a given type
|
|
2755
|
+
*
|
|
2756
|
+
* ## [#14423] One loader's fault does not take the whole enumeration down
|
|
2757
|
+
*
|
|
2758
|
+
* This loop used to be bare — `const result = await loader.list(type)` with
|
|
2759
|
+
* no `try`, while the two sibling plural reads (`list()` via
|
|
2760
|
+
* {@link admitLoaderItems}, and {@link loadMany}) have carried a per-loader
|
|
2761
|
+
* `catch` since #5108. That asymmetry is the defect, independent of any one
|
|
2762
|
+
* caller: the SAME storage outage was swallowed by one plural read and
|
|
2763
|
+
* thrown out of the other, so which answer a caller got depended only on
|
|
2764
|
+
* which method it happened to call. A caller reading both — the action
|
|
2765
|
+
* governance audit is one — saw `loadMany` report a short-but-successful
|
|
2766
|
+
* set and `listNames` throw, and had no way to tell that one fact was
|
|
2767
|
+
* behind both.
|
|
2768
|
+
*
|
|
2769
|
+
* Same shape as `loadMany`'s, deliberately, down to the helpers: the outage
|
|
2770
|
+
* is spoken once per loader through {@link reportLoaderReadFailure} and
|
|
2771
|
+
* un-said through {@link reportLoaderReadRecovered}. ⛔ Not a third spelling
|
|
2772
|
+
* for "a loader faulted" — a second vocabulary for one event is how the two
|
|
2773
|
+
* reads drifted apart in the first place.
|
|
2774
|
+
*
|
|
2775
|
+
* The degradation is the same one `list()` documents and is graded the same
|
|
2776
|
+
* way (AGENTS.md → "Degradation log levels"): the caller still gets an
|
|
2777
|
+
* array, nothing 500s, and the set is quietly short — so it is reported at
|
|
2778
|
+
* `error`, by the shared helper, rather than being re-graded here.
|
|
2676
2779
|
*/
|
|
2677
2780
|
async listNames(type) {
|
|
2678
2781
|
type = canonicalMetadataServiceType(type);
|
|
@@ -2684,8 +2787,16 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2684
2787
|
}
|
|
2685
2788
|
}
|
|
2686
2789
|
for (const loader of this.loaders.values()) {
|
|
2687
|
-
|
|
2688
|
-
|
|
2790
|
+
try {
|
|
2791
|
+
const result = await loader.list(type);
|
|
2792
|
+
result.forEach((item) => names.add(item));
|
|
2793
|
+
this.reportLoaderReadRecovered(loader.contract.name);
|
|
2794
|
+
} catch (e) {
|
|
2795
|
+
if (isAmbiguousMetadataStemError(e)) {
|
|
2796
|
+
throw e;
|
|
2797
|
+
}
|
|
2798
|
+
this.reportLoaderReadFailure(loader.contract.name, type, e);
|
|
2799
|
+
}
|
|
2689
2800
|
}
|
|
2690
2801
|
return Array.from(names);
|
|
2691
2802
|
}
|
|
@@ -2731,12 +2842,54 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2731
2842
|
* Runtime-authored `shared` / `personal` views (`sys_view_definition`) are
|
|
2732
2843
|
* merged in by the REST layer; this method returns the `package` layer that
|
|
2733
2844
|
* was registered from source.
|
|
2845
|
+
*
|
|
2846
|
+
* ## [#13913] Aggregated containers are expanded inline, per read
|
|
2847
|
+
*
|
|
2848
|
+
* `this.list('view')` is `MetadataManager`'s OWN loader-based store — the
|
|
2849
|
+
* in-memory registry plus every registered loader — and is a completely
|
|
2850
|
+
* different store from the `sys_metadata` rows `getMetaItems` reads. #13407
|
|
2851
|
+
* taught `getMetaItems` to expand a runtime-authored aggregated container
|
|
2852
|
+
* inline; this exit never called it and had no equivalent step, so a
|
|
2853
|
+
* container that `GET /meta/view?object=` now serves still answered **empty**
|
|
2854
|
+
* here.
|
|
2855
|
+
*
|
|
2856
|
+
* Merely getting the container into the store would not have helped: the
|
|
2857
|
+
* filter also requires `viewKind`, and a container has none. Loosening that
|
|
2858
|
+
* requirement is NOT the repair — it would answer with the container itself
|
|
2859
|
+
* as a view, the behaviour #7163 ruled wrong — so what is added below is the
|
|
2860
|
+
* container's **expansion**, whose items each carry the `viewKind` + `object`
|
|
2861
|
+
* pair this filter has always tested. The filter itself is untouched: it
|
|
2862
|
+
* reads the top-level `object`, exactly as `ViewSchema.object` declares.
|
|
2863
|
+
*
|
|
2864
|
+
* Registry-free and per-read, mirroring #13407's choice at the other exit and
|
|
2865
|
+
* for the same reason — the registry is process-wide, so a read must not
|
|
2866
|
+
* graft rows into it (see `view-container-expansion.ts`'s header, which also
|
|
2867
|
+
* records why the protocol's copy of this logic cannot be imported).
|
|
2868
|
+
*
|
|
2869
|
+
* Already-present items win: an expansion contributes only names the store
|
|
2870
|
+
* does not already hold, so a container whose expanded ViewItems were
|
|
2871
|
+
* registered by a source registrar (the ObjectQL boot loop, the artifact/HMR
|
|
2872
|
+
* loader) still answers with those registered, fully-enriched items and this
|
|
2873
|
+
* step adds nothing.
|
|
2734
2874
|
*/
|
|
2735
2875
|
async getViewsByObject(object) {
|
|
2736
2876
|
const views = await this.list("view");
|
|
2737
|
-
|
|
2877
|
+
const matches = views.filter(
|
|
2738
2878
|
(v) => v && typeof v === "object" && v.viewKind && v.object === object
|
|
2739
|
-
)
|
|
2879
|
+
);
|
|
2880
|
+
const known = /* @__PURE__ */ new Set();
|
|
2881
|
+
for (const v of views) {
|
|
2882
|
+
if (v && typeof v === "object" && typeof v.name === "string") known.add(v.name);
|
|
2883
|
+
}
|
|
2884
|
+
for (const v of views) {
|
|
2885
|
+
for (const item of expandRuntimeViewContainer(v)) {
|
|
2886
|
+
if (!item.viewKind || item.object !== object) continue;
|
|
2887
|
+
if (known.has(item.name)) continue;
|
|
2888
|
+
known.add(item.name);
|
|
2889
|
+
matches.push(item);
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
return matches.sort(
|
|
2740
2893
|
(a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.name).localeCompare(String(b.name))
|
|
2741
2894
|
);
|
|
2742
2895
|
}
|
|
@@ -3154,66 +3307,18 @@ var _MetadataManager = class _MetadataManager {
|
|
|
3154
3307
|
};
|
|
3155
3308
|
}
|
|
3156
3309
|
// ==========================================
|
|
3157
|
-
// Overlay / Customization Management
|
|
3310
|
+
// Overlay / Customization Management — REMOVED (#13135, ADR-0049)
|
|
3158
3311
|
// ==========================================
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
* Save/update an overlay for a metadata item
|
|
3170
|
-
*/
|
|
3171
|
-
async saveOverlay(overlay) {
|
|
3172
|
-
if (this.config.persistence?.overlayWritable === false) {
|
|
3173
|
-
const msg = `MetadataManager overlays are read-only (persistence.overlayWritable=false); refusing to save overlay for ${overlay.baseType}/${overlay.baseName}`;
|
|
3174
|
-
if (this.config.validation?.throwOnError) {
|
|
3175
|
-
throw new Error(msg);
|
|
3176
|
-
}
|
|
3177
|
-
this.logger.warn(msg);
|
|
3178
|
-
return;
|
|
3179
|
-
}
|
|
3180
|
-
const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
|
|
3181
|
-
this.overlays.set(key, overlay);
|
|
3182
|
-
}
|
|
3183
|
-
/**
|
|
3184
|
-
* Remove an overlay, reverting to the base definition
|
|
3185
|
-
*/
|
|
3186
|
-
async removeOverlay(type, name, scope) {
|
|
3187
|
-
this.overlays.delete(this.overlayKey(type, name, scope ?? "platform"));
|
|
3188
|
-
}
|
|
3189
|
-
/**
|
|
3190
|
-
* Get the effective (merged) metadata after applying all overlays.
|
|
3191
|
-
* Resolution order: system ← merge(platform) ← merge(user)
|
|
3192
|
-
*/
|
|
3193
|
-
async getEffective(type, name, context) {
|
|
3194
|
-
const base = await this.get(type, name);
|
|
3195
|
-
if (!base) return void 0;
|
|
3196
|
-
let effective = { ...base };
|
|
3197
|
-
const platformOverlay = await this.getOverlay(type, name, "platform");
|
|
3198
|
-
if (platformOverlay?.active && platformOverlay.patch) {
|
|
3199
|
-
effective = { ...effective, ...platformOverlay.patch };
|
|
3200
|
-
}
|
|
3201
|
-
if (context?.userId) {
|
|
3202
|
-
const userOverlayKey = this.overlayKey(type, name, "user") + `:${context.userId}`;
|
|
3203
|
-
const userOverlay = this.overlays.get(userOverlayKey) ?? await this.getOverlay(type, name, "user");
|
|
3204
|
-
if (userOverlay?.active && userOverlay.patch) {
|
|
3205
|
-
if (!userOverlay.owner || userOverlay.owner === context.userId) {
|
|
3206
|
-
effective = { ...effective, ...userOverlay.patch };
|
|
3207
|
-
}
|
|
3208
|
-
}
|
|
3209
|
-
} else {
|
|
3210
|
-
const userOverlay = await this.getOverlay(type, name, "user");
|
|
3211
|
-
if (userOverlay?.active && userOverlay.patch && !userOverlay.owner) {
|
|
3212
|
-
effective = { ...effective, ...userOverlay.patch };
|
|
3213
|
-
}
|
|
3214
|
-
}
|
|
3215
|
-
return effective;
|
|
3216
|
-
}
|
|
3312
|
+
//
|
|
3313
|
+
// The in-memory overlay limb (`getOverlay` / `saveOverlay` / `removeOverlay`
|
|
3314
|
+
// / `getEffective`, keyed `type:name:scope`) implemented the paper
|
|
3315
|
+
// metadata-customization protocol removed from `@objectstack/spec` in the
|
|
3316
|
+
// same change: no route ever served the paper `.../overlay` or
|
|
3317
|
+
// `.../effective` endpoints, and the only callers of these methods were this
|
|
3318
|
+
// package's own unit tests. ADR-0126 supersedes the protocol on the record.
|
|
3319
|
+
// The org-scoped customization that actually ships is ADR-0005's
|
|
3320
|
+
// `sys_metadata` overlay (`getMetaItemLayered` in metadata-protocol), which
|
|
3321
|
+
// never lived here.
|
|
3217
3322
|
// ==========================================
|
|
3218
3323
|
// Watch / Subscribe (IMetadataService)
|
|
3219
3324
|
// ==========================================
|
|
@@ -3551,6 +3656,99 @@ var _MetadataManager = class _MetadataManager {
|
|
|
3551
3656
|
}
|
|
3552
3657
|
return results;
|
|
3553
3658
|
}
|
|
3659
|
+
/**
|
|
3660
|
+
* [#14423] {@link loadMany}, read under the identity the STORE holds each
|
|
3661
|
+
* item by — the keyed plural read, beside the unkeyed one.
|
|
3662
|
+
*
|
|
3663
|
+
* ## Why a second method and not a widened `loadMany`
|
|
3664
|
+
*
|
|
3665
|
+
* `loadMany` keys nothing: it returns bodies, and every consumer that needs
|
|
3666
|
+
* an identity reads `body.name` off them. #14205 already ruled what identity
|
|
3667
|
+
* IS — the key the store holds the item under (`register(type, name, data)`
|
|
3668
|
+
* takes it as the ARGUMENT, and a body is not required to name itself) — so
|
|
3669
|
+
* `body.name` is a guess that happens to be right for most items and drops
|
|
3670
|
+
* the rest ENTIRELY: an item whose body carries no `name` is served by
|
|
3671
|
+
* `load(type, name)` and is not nameable from `loadMany`'s answer at all.
|
|
3672
|
+
*
|
|
3673
|
+
* Widening `loadMany`'s return would fix that and break every consumer of a
|
|
3674
|
+
* published shape (the ones counted on this card all read `body.name` as the
|
|
3675
|
+
* identity). So this is additive: `loadMany`'s return shape is untouched,
|
|
3676
|
+
* and a caller that needs the key asks for the key.
|
|
3677
|
+
*
|
|
3678
|
+
* ## What it reads — the same population `loadMany` reads
|
|
3679
|
+
*
|
|
3680
|
+
* Loaders only, deliberately, so this is `loadMany` keyed and nothing more.
|
|
3681
|
+
* It is NOT `list()`/{@link listNames}, which also merge the in-memory
|
|
3682
|
+
* `register()` registry; a caller wanting that set has those. Reading the
|
|
3683
|
+
* loaders alone is also what makes this the enumerable twin of
|
|
3684
|
+
* {@link loadDiagnosed}, which walks the same loaders by name — that pairing
|
|
3685
|
+
* is the point on the audit side of #14423, where an enumeration and a
|
|
3686
|
+
* by-name read that disagree about a population make one subsystem accuse
|
|
3687
|
+
* another of a defect neither has.
|
|
3688
|
+
*
|
|
3689
|
+
* ## Delegate first, fall back second — and why that order is not a style
|
|
3690
|
+
*
|
|
3691
|
+
* Per loader: {@link MetadataLoader.loadManyKeyed} where the loader offers
|
|
3692
|
+
* one, else its `list()` + a per-name `load()`. Measured, on
|
|
3693
|
+
* `DatabaseLoader`: the keyed method shares `loadMany`'s single query
|
|
3694
|
+
* (`{find:1, findOne:0}` — zero extra cost), while enumerate-then-read-each
|
|
3695
|
+
* on that same loader is a real N+1 (`{find:1, findOne:5}` for five items).
|
|
3696
|
+
* The fallback exists for loaders that cannot produce keys at all
|
|
3697
|
+
* (`RemoteLoader`'s wire format carries bodies only), and it recovers the
|
|
3698
|
+
* nameless item the pre-#14205 `loadMany`-and-key-by-`body.name` fallback
|
|
3699
|
+
* drops — which is why it is `list()` + `load()` and not `loadMany()`.
|
|
3700
|
+
*
|
|
3701
|
+
* ## Failure posture
|
|
3702
|
+
*
|
|
3703
|
+
* Per-loader `try`/`catch`, the same seam and the same helpers as
|
|
3704
|
+
* {@link loadMany} and `list()` — one loader's outage does not take the
|
|
3705
|
+
* enumeration down, and it is reported once through
|
|
3706
|
+
* {@link reportLoaderReadFailure} rather than in a third vocabulary.
|
|
3707
|
+
* Earlier loaders win a key collision, mirroring `list()`.
|
|
3708
|
+
*/
|
|
3709
|
+
async loadManyKeyed(type, options) {
|
|
3710
|
+
const items = /* @__PURE__ */ new Map();
|
|
3711
|
+
for (const loader of this.loaders.values()) {
|
|
3712
|
+
try {
|
|
3713
|
+
await this.admitKeyedLoaderItems(loader, type, items, options);
|
|
3714
|
+
this.reportLoaderReadRecovered(loader.contract.name);
|
|
3715
|
+
} catch (e) {
|
|
3716
|
+
this.reportLoaderReadFailure(loader.contract.name, type, e);
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
return Array.from(items, ([name, data]) => ({ name, data }));
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Merge ONE loader's answer for `type` into `items`, keyed by that loader's
|
|
3723
|
+
* own key for each item — {@link loadManyKeyed}'s per-loader body.
|
|
3724
|
+
*
|
|
3725
|
+
* Distinct from {@link admitLoaderItems} on exactly one axis, and that axis
|
|
3726
|
+
* is the whole of #14423: the fallback for a loader with no
|
|
3727
|
+
* `loadManyKeyed`. `admitLoaderItems` falls back to `loadMany` keyed by
|
|
3728
|
+
* `data.name` — the pre-#14205 behaviour, verbatim, which drops a nameless
|
|
3729
|
+
* body. Here the fallback is `list()` + a per-name `load()`, so a loader
|
|
3730
|
+
* that cannot enumerate keys and bodies together still answers with both.
|
|
3731
|
+
*
|
|
3732
|
+
* Read failures are NOT caught here — the caller owns that verdict, as in
|
|
3733
|
+
* {@link admitLoaderItems}.
|
|
3734
|
+
*/
|
|
3735
|
+
async admitKeyedLoaderItems(loader, type, items, options) {
|
|
3736
|
+
if (typeof loader.loadManyKeyed === "function") {
|
|
3737
|
+
const keyed = await loader.loadManyKeyed(type, options);
|
|
3738
|
+
for (const entry of keyed) {
|
|
3739
|
+
if (!entry || typeof entry.name !== "string" || entry.name === "") continue;
|
|
3740
|
+
if (items.has(entry.name)) continue;
|
|
3741
|
+
items.set(entry.name, entry.data);
|
|
3742
|
+
}
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
for (const name of await loader.list(type)) {
|
|
3746
|
+
if (typeof name !== "string" || name === "" || items.has(name)) continue;
|
|
3747
|
+
const result = await loader.load(type, name, options);
|
|
3748
|
+
if (result?.data == null) continue;
|
|
3749
|
+
items.set(name, result.data);
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3554
3752
|
/**
|
|
3555
3753
|
* Save metadata item to a loader
|
|
3556
3754
|
*/
|
|
@@ -3987,6 +4185,7 @@ var MetadataManager = _MetadataManager;
|
|
|
3987
4185
|
// src/plugin.ts
|
|
3988
4186
|
import { readFile as readFile2 } from "fs/promises";
|
|
3989
4187
|
import { createHash as createHash2 } from "crypto";
|
|
4188
|
+
import { resolveArtifactPackageOrder, artifactPackageId } from "@objectstack/core";
|
|
3990
4189
|
|
|
3991
4190
|
// src/node-metadata-manager.ts
|
|
3992
4191
|
import * as path2 from "path";
|
|
@@ -3997,7 +4196,11 @@ import * as fs from "fs/promises";
|
|
|
3997
4196
|
import * as path from "path";
|
|
3998
4197
|
import { glob } from "glob";
|
|
3999
4198
|
import { createHash } from "crypto";
|
|
4000
|
-
|
|
4199
|
+
function ownNameOf(data) {
|
|
4200
|
+
const own = data?.name;
|
|
4201
|
+
return typeof own === "string" && own !== "" ? own : null;
|
|
4202
|
+
}
|
|
4203
|
+
var _FilesystemLoader = class _FilesystemLoader {
|
|
4001
4204
|
constructor(rootDir, serializers, logger) {
|
|
4002
4205
|
this.rootDir = rootDir;
|
|
4003
4206
|
this.serializers = serializers;
|
|
@@ -4095,6 +4298,82 @@ var FilesystemLoader = class {
|
|
|
4095
4298
|
}
|
|
4096
4299
|
}
|
|
4097
4300
|
async loadMany(type, options) {
|
|
4301
|
+
return (await this.loadManyEntries(type, options)).map((entry) => entry.data);
|
|
4302
|
+
}
|
|
4303
|
+
/**
|
|
4304
|
+
* [#14341] The keyed half of {@link loadMany} — see {@link MetadataKeyedItem}
|
|
4305
|
+
* for why the store's key travels BESIDE the body instead of being folded
|
|
4306
|
+
* into it.
|
|
4307
|
+
*
|
|
4308
|
+
* THE RULE, in one sentence: an item is keyed by this loader's own
|
|
4309
|
+
* name-to-path derivation — {@link nameFromFilename}, the very basename
|
|
4310
|
+
* derivation `list()` reports — ONLY where that derivation is a bijection for
|
|
4311
|
+
* the file (it sits directly under `ROOT/TYPE/` and carries one of the
|
|
4312
|
+
* extensions {@link findFile} tries, so `findFile(type, key)` resolves back to
|
|
4313
|
+
* this same file); every other shape keeps the pre-#14205 behaviour verbatim,
|
|
4314
|
+
* keyed by `body.name` when it has one and dropped when it has none.
|
|
4315
|
+
*
|
|
4316
|
+
* Why the rule stops there (PM ruling on #14341, 2026-09-02, knowingly over
|
|
4317
|
+
* triage's "a nested path keeps whatever `list()` reports for it today"):
|
|
4318
|
+
* `list()` and `findFile()` DISAGREE outside that shape. For
|
|
4319
|
+
* `ROOT/TYPE/crm/account.json`, `list()` reports the bare `account`, but
|
|
4320
|
+
* `findFile()` resolves that name against `ROOT/TYPE/account.json` and finds
|
|
4321
|
+
* nothing — the only name reaching the file is `crm/account`, which nothing
|
|
4322
|
+
* reports. An extension-less file is read by `loadMany()` and reported by
|
|
4323
|
+
* `list()`, and `findFile()` resolves neither. Keying by either side would
|
|
4324
|
+
* mint a name some other door cannot open, and two directories holding the
|
|
4325
|
+
* same basename would collide in silence
|
|
4326
|
+
* (`MetadataManager.admitLoaderItems()` keeps the first and says nothing).
|
|
4327
|
+
* The card's own fence: "keying items under names nothing else uses … is
|
|
4328
|
+
* worse than today's honest drop". So the drop stays exactly where the key is
|
|
4329
|
+
* unsettled, and is pinned as a RECORD in
|
|
4330
|
+
* `filesystem-loader-keyed-items.test.ts`.
|
|
4331
|
+
*
|
|
4332
|
+
* [#14486, partial] `list()` and {@link findFile} have since converged on
|
|
4333
|
+
* {@link resolvableNameForPath} — the derivation this method already used —
|
|
4334
|
+
* so a nested or extension-less file is now neither listed nor resolvable.
|
|
4335
|
+
* What did NOT change is the WALK behind this method: `loadManyEntries()`
|
|
4336
|
+
* still READS those files, so `loadMany()` still returns their bodies and
|
|
4337
|
+
* this method still falls back to `body.name` for them. That half of the
|
|
4338
|
+
* #14486 ruling ("nothing unlisted is returned by `loadMany()` either") is
|
|
4339
|
+
* deliberately NOT taken here: it would invert the three landed #14341 pins
|
|
4340
|
+
* in `filesystem-loader-keyed-items.test.ts:113,167,187` and the
|
|
4341
|
+
* `loadMany()` CONTROL at `:196`, and that file was under a concurrent
|
|
4342
|
+
* claim (PR #14627) when this landed. The remaining divergence — listed ⊂
|
|
4343
|
+
* loaded — is pinned as a RECORD in
|
|
4344
|
+
* `filesystem-loader-list-reachability.test.ts` rather than left implicit.
|
|
4345
|
+
*
|
|
4346
|
+
* One consequence, deliberate: a flat file whose `body.name` DISAGREES with
|
|
4347
|
+
* its basename is now keyed by the BASENAME. That is #14205's rule (identity
|
|
4348
|
+
* is the key the store holds an item under, not `body.name`) applied to this
|
|
4349
|
+
* loader, and it aligns `MetadataManager.list()` with `listNames()` for that
|
|
4350
|
+
* shape.
|
|
4351
|
+
*
|
|
4352
|
+
* The body is handed back by reference, unchanged: nothing is written into a
|
|
4353
|
+
* body that deliberately has no `name`. `limit` bounds the items LOADED,
|
|
4354
|
+
* exactly as `loadMany()` does — an entry the key rule drops has still been
|
|
4355
|
+
* read and still counts against it.
|
|
4356
|
+
*/
|
|
4357
|
+
async loadManyKeyed(type, options) {
|
|
4358
|
+
const typeDir = path.join(this.rootDir, type);
|
|
4359
|
+
const keyed = [];
|
|
4360
|
+
for (const entry of await this.loadManyEntries(type, options)) {
|
|
4361
|
+
const name = this.resolvableNameForPath(typeDir, entry.file) ?? ownNameOf(entry.data);
|
|
4362
|
+
if (name) {
|
|
4363
|
+
keyed.push({ name, data: entry.data });
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
return keyed;
|
|
4367
|
+
}
|
|
4368
|
+
/**
|
|
4369
|
+
* The single walk behind {@link loadMany} and {@link loadManyKeyed}: one glob,
|
|
4370
|
+
* one serializer pass, one `limit`. Shared so the two can never answer with
|
|
4371
|
+
* different bodies for the same file — {@link MetadataLoader.loadManyKeyed}
|
|
4372
|
+
* requires `data` to be "the same body `loadMany()` would return for the
|
|
4373
|
+
* item", and a second copy of this walk is how that would quietly stop being
|
|
4374
|
+
* true.
|
|
4375
|
+
*/
|
|
4376
|
+
async loadManyEntries(type, options) {
|
|
4098
4377
|
const { patterns = ["**/*"], recursive: _recursive = true, limit } = options || {};
|
|
4099
4378
|
const typeDir = path.join(this.rootDir, type);
|
|
4100
4379
|
const items = [];
|
|
@@ -4102,33 +4381,34 @@ var FilesystemLoader = class {
|
|
|
4102
4381
|
const globPatterns = patterns.map(
|
|
4103
4382
|
(pattern) => path.join(typeDir, pattern)
|
|
4104
4383
|
);
|
|
4384
|
+
const files = [];
|
|
4105
4385
|
for (const pattern of globPatterns) {
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
const content = await fs.readFile(file, "utf-8");
|
|
4116
|
-
const format = this.detectFormat(file);
|
|
4117
|
-
const serializer = this.getSerializer(format);
|
|
4118
|
-
if (serializer) {
|
|
4119
|
-
const data = serializer.deserialize(content);
|
|
4120
|
-
items.push(data);
|
|
4121
|
-
}
|
|
4122
|
-
} catch (error) {
|
|
4123
|
-
this.logger?.warn("Failed to load file", {
|
|
4124
|
-
file,
|
|
4125
|
-
error: error instanceof Error ? error.message : String(error)
|
|
4126
|
-
});
|
|
4127
|
-
}
|
|
4128
|
-
}
|
|
4386
|
+
files.push(
|
|
4387
|
+
...await glob(pattern, {
|
|
4388
|
+
ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/*[*]*"],
|
|
4389
|
+
nodir: true
|
|
4390
|
+
})
|
|
4391
|
+
);
|
|
4392
|
+
}
|
|
4393
|
+
this.resolvableNames(type, typeDir, files);
|
|
4394
|
+
for (const file of files) {
|
|
4129
4395
|
if (limit && items.length >= limit) {
|
|
4130
4396
|
break;
|
|
4131
4397
|
}
|
|
4398
|
+
try {
|
|
4399
|
+
const content = await fs.readFile(file, "utf-8");
|
|
4400
|
+
const format = this.detectFormat(file);
|
|
4401
|
+
const serializer = this.getSerializer(format);
|
|
4402
|
+
if (serializer) {
|
|
4403
|
+
const data = serializer.deserialize(content);
|
|
4404
|
+
items.push({ file, data });
|
|
4405
|
+
}
|
|
4406
|
+
} catch (error) {
|
|
4407
|
+
this.logger?.warn("Failed to load file", {
|
|
4408
|
+
file,
|
|
4409
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4410
|
+
});
|
|
4411
|
+
}
|
|
4132
4412
|
}
|
|
4133
4413
|
return items;
|
|
4134
4414
|
} catch (error) {
|
|
@@ -4171,19 +4451,39 @@ var FilesystemLoader = class {
|
|
|
4171
4451
|
return null;
|
|
4172
4452
|
}
|
|
4173
4453
|
}
|
|
4454
|
+
/**
|
|
4455
|
+
* [#14486] The names this loader can be asked for, and ONLY those: a file
|
|
4456
|
+
* directly under `ROOT/TYPE/` carrying an extension one of this instance's
|
|
4457
|
+
* REGISTERED serializers claims. Every name it reports resolves back through
|
|
4458
|
+
* {@link findFile}, so `listNames()` and `get()` give the same answer.
|
|
4459
|
+
*
|
|
4460
|
+
* It used to report `path.basename(file, ext)` for every file the glob found,
|
|
4461
|
+
* nested or not, extension or not — and {@link findFile} resolves neither
|
|
4462
|
+
* shape. `ROOT/TYPE/crm/account.json` was listed as `account`, which resolves
|
|
4463
|
+
* against `ROOT/TYPE/account.json` and finds nothing; an extension-less
|
|
4464
|
+
* `ROOT/TYPE/noext` was listed as `noext`, which resolves under no appended
|
|
4465
|
+
* extension at all. A name in the list that `get()` answers `null` for is the
|
|
4466
|
+
* silent failure an author (human or AI) reads as their own typo, so they
|
|
4467
|
+
* retry the same word: the list and the door now agree instead.
|
|
4468
|
+
*
|
|
4469
|
+
* Ruling (maintainer, via the director seat on #14486, 2026-09-02): narrow
|
|
4470
|
+
* the list — direction A, over B (reverse-unify: report `crm/account` and
|
|
4471
|
+
* teach `findFile()` path-shaped names), which would have made a slash inside
|
|
4472
|
+
* a metadata name every consumer's permanent obligation with no measured
|
|
4473
|
+
* demand for it. The two-segment layout follows ADR-0008 §10, which
|
|
4474
|
+
* `metadata-fs`'s `parseItemPath()` already enforces for its own store; the
|
|
4475
|
+
* EXTENSION set deliberately does NOT follow §10's `.json`-only rule — see
|
|
4476
|
+
* {@link resolvableExtensions} for why.
|
|
4477
|
+
*/
|
|
4174
4478
|
async list(type) {
|
|
4175
4479
|
const typeDir = path.join(this.rootDir, type);
|
|
4480
|
+
let files;
|
|
4176
4481
|
try {
|
|
4177
|
-
|
|
4482
|
+
files = await glob("**/*", {
|
|
4178
4483
|
cwd: typeDir,
|
|
4179
4484
|
ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"],
|
|
4180
4485
|
nodir: true
|
|
4181
4486
|
});
|
|
4182
|
-
return files.map((file) => {
|
|
4183
|
-
const ext = path.extname(file);
|
|
4184
|
-
const basename3 = path.basename(file, ext);
|
|
4185
|
-
return basename3;
|
|
4186
|
-
});
|
|
4187
4487
|
} catch (error) {
|
|
4188
4488
|
this.logger?.error("Failed to list", void 0, {
|
|
4189
4489
|
type,
|
|
@@ -4191,6 +4491,7 @@ var FilesystemLoader = class {
|
|
|
4191
4491
|
});
|
|
4192
4492
|
return [];
|
|
4193
4493
|
}
|
|
4494
|
+
return this.resolvableNames(type, typeDir, files.map((file) => path.join(typeDir, file)));
|
|
4194
4495
|
}
|
|
4195
4496
|
async save(type, name, data, options) {
|
|
4196
4497
|
const startTime = Date.now();
|
|
@@ -4261,12 +4562,109 @@ var FilesystemLoader = class {
|
|
|
4261
4562
|
throw error;
|
|
4262
4563
|
}
|
|
4263
4564
|
}
|
|
4565
|
+
/**
|
|
4566
|
+
* [#14486] The extensions a name can be resolved under, for THIS instance:
|
|
4567
|
+
* the ones belonging to the serializer set it was constructed with. Shared by
|
|
4568
|
+
* {@link findFile}, {@link resolvableNameForPath} and therefore {@link list},
|
|
4569
|
+
* so the set a name can be RESOLVED under cannot drift from the set that is
|
|
4570
|
+
* LISTED or the set {@link loadManyKeyed} is willing to KEY by.
|
|
4571
|
+
*
|
|
4572
|
+
* Registered, not hard-coded, and deliberately not ADR-0008 §10's `.json`
|
|
4573
|
+
* only. §10 governs the `metadata-fs` store; applying it verbatim here would
|
|
4574
|
+
* drop `.yaml` and `.ts` metadata out of `listNames()` — a breakage this card
|
|
4575
|
+
* never asked for. Under the manager's DEFAULT format set
|
|
4576
|
+
* (`typescript` / `json` / `yaml`, `metadata-manager.ts`) that leaves `.js`
|
|
4577
|
+
* out, which is the card's row-4 membership mismatch closing for free: a `.js`
|
|
4578
|
+
* file was listed and resolvable while `loadMany()` could never return it and
|
|
4579
|
+
* `load()` threw `No serializer found for format: javascript`. Register
|
|
4580
|
+
* `javascript` and it is listed, resolvable and loadable together.
|
|
4581
|
+
*/
|
|
4582
|
+
resolvableExtensions() {
|
|
4583
|
+
const extensions = [];
|
|
4584
|
+
for (const [format, formatExtensions] of _FilesystemLoader.EXTENSIONS_BY_FORMAT) {
|
|
4585
|
+
if (this.serializers.has(format)) {
|
|
4586
|
+
extensions.push(...formatExtensions);
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
return extensions;
|
|
4590
|
+
}
|
|
4591
|
+
/**
|
|
4592
|
+
* The metadata name this loader reports for a file: the basename with its
|
|
4593
|
+
* extension stripped. One derivation, shared by {@link list} and
|
|
4594
|
+
* {@link loadManyKeyed}, so the two cannot drift for the shape where they
|
|
4595
|
+
* agree — `dotted.config.json` is `dotted.config` for both.
|
|
4596
|
+
*/
|
|
4597
|
+
static nameFromFilename(file) {
|
|
4598
|
+
return path.basename(file, path.extname(file));
|
|
4599
|
+
}
|
|
4600
|
+
/**
|
|
4601
|
+
* The key for a file IF this loader's name-to-path mapping is a bijection for
|
|
4602
|
+
* it: a file directly under `ROOT/TYPE/` carrying an extension
|
|
4603
|
+
* {@link findFile} tries, so `findFile(type, key)` resolves back to this very
|
|
4604
|
+
* file. `null` for every other shape — a nested path, an extension-less file,
|
|
4605
|
+
* an extension spelled in a case `findFile()` does not compose — which is why
|
|
4606
|
+
* {@link loadManyKeyed} falls back to `body.name` there rather than minting a
|
|
4607
|
+
* key no other door can open.
|
|
4608
|
+
*/
|
|
4609
|
+
resolvableNameForPath(typeDir, file) {
|
|
4610
|
+
const rel = path.relative(typeDir, file);
|
|
4611
|
+
if (rel === "" || rel.split(path.sep).length !== 1) {
|
|
4612
|
+
return null;
|
|
4613
|
+
}
|
|
4614
|
+
if (!this.resolvableExtensions().includes(path.extname(rel))) {
|
|
4615
|
+
return null;
|
|
4616
|
+
}
|
|
4617
|
+
return _FilesystemLoader.nameFromFilename(rel);
|
|
4618
|
+
}
|
|
4619
|
+
/**
|
|
4620
|
+
* [#14921] The names this loader reports for `files` — and the ONE place an
|
|
4621
|
+
* ambiguous stem is refused.
|
|
4622
|
+
*
|
|
4623
|
+
* Shared by {@link list} and {@link loadManyEntries} so the two can never
|
|
4624
|
+
* disagree about which trees are admissible: a stem that `list()` refuses
|
|
4625
|
+
* must not still be walked and returned as two bodies by `loadMany()`, which
|
|
4626
|
+
* is exactly the split this card measured.
|
|
4627
|
+
*
|
|
4628
|
+
* Refuses on the FIRST colliding name in sorted order, so a tree holding more
|
|
4629
|
+
* than one collision always names the same one — a refusal that moves
|
|
4630
|
+
* between runs reads as flakiness rather than as the fixed authoring error it
|
|
4631
|
+
* is. Paths are deduplicated because two overlapping `patterns` legitimately
|
|
4632
|
+
* match one file twice, and counting that as a collision would refuse a
|
|
4633
|
+
* perfectly good tree.
|
|
4634
|
+
*
|
|
4635
|
+
* ⛔ Not a precedence resolver. Picking a winner here is what the ruling
|
|
4636
|
+
* declined (option 2, keep the precedence and log): the loser would stay
|
|
4637
|
+
* unreachable and the listed set would stay different from the addressable
|
|
4638
|
+
* one.
|
|
4639
|
+
*/
|
|
4640
|
+
resolvableNames(type, typeDir, files) {
|
|
4641
|
+
const byName = /* @__PURE__ */ new Map();
|
|
4642
|
+
for (const file of files) {
|
|
4643
|
+
const name = this.resolvableNameForPath(typeDir, file);
|
|
4644
|
+
if (name === null) {
|
|
4645
|
+
continue;
|
|
4646
|
+
}
|
|
4647
|
+
let paths = byName.get(name);
|
|
4648
|
+
if (!paths) {
|
|
4649
|
+
paths = /* @__PURE__ */ new Set();
|
|
4650
|
+
byName.set(name, paths);
|
|
4651
|
+
}
|
|
4652
|
+
paths.add(file);
|
|
4653
|
+
}
|
|
4654
|
+
for (const name of [...byName.keys()].sort()) {
|
|
4655
|
+
const paths = byName.get(name);
|
|
4656
|
+
if (paths.size > 1) {
|
|
4657
|
+
throw new AmbiguousMetadataStemError(type, name, [...paths]);
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
return [...byName.keys()];
|
|
4661
|
+
}
|
|
4264
4662
|
/**
|
|
4265
4663
|
* Find file for a given type and name
|
|
4266
4664
|
*/
|
|
4267
4665
|
async findFile(type, name) {
|
|
4268
4666
|
const typeDir = path.join(this.rootDir, type);
|
|
4269
|
-
const extensions =
|
|
4667
|
+
const extensions = this.resolvableExtensions();
|
|
4270
4668
|
for (const ext of extensions) {
|
|
4271
4669
|
const filePath = path.join(typeDir, `${name}${ext}`);
|
|
4272
4670
|
try {
|
|
@@ -4312,6 +4710,19 @@ var FilesystemLoader = class {
|
|
|
4312
4710
|
return `"${hash}"`;
|
|
4313
4711
|
}
|
|
4314
4712
|
};
|
|
4713
|
+
/**
|
|
4714
|
+
* The inverse of {@link detectFormat}: which file extensions carry which
|
|
4715
|
+
* format. Fixed ORDER, because it is also {@link findFile}'s precedence when
|
|
4716
|
+
* two files under one type directory share a stem — registration order must
|
|
4717
|
+
* not be able to change which file `ROOT/TYPE/NAME` opens.
|
|
4718
|
+
*/
|
|
4719
|
+
_FilesystemLoader.EXTENSIONS_BY_FORMAT = [
|
|
4720
|
+
["json", [".json"]],
|
|
4721
|
+
["yaml", [".yaml", ".yml"]],
|
|
4722
|
+
["typescript", [".ts"]],
|
|
4723
|
+
["javascript", [".js"]]
|
|
4724
|
+
];
|
|
4725
|
+
var FilesystemLoader = _FilesystemLoader;
|
|
4315
4726
|
|
|
4316
4727
|
// src/node-metadata-manager.ts
|
|
4317
4728
|
var NodeMetadataManager = class extends MetadataManager {
|
|
@@ -4436,6 +4847,20 @@ var MemoryLoader = class {
|
|
|
4436
4847
|
if (!typeStore) return [];
|
|
4437
4848
|
return Array.from(typeStore.values());
|
|
4438
4849
|
}
|
|
4850
|
+
/**
|
|
4851
|
+
* [#14205] The keyed half of {@link loadMany}. The storage map is already
|
|
4852
|
+
* `Type -> Name -> Data`, so the key this loader holds an item under is the
|
|
4853
|
+
* map key — `loadMany()` was simply discarding it, which dropped every
|
|
4854
|
+
* nameless body out of `MetadataManager.list()` and out of the endpoint index.
|
|
4855
|
+
*
|
|
4856
|
+
* The body is handed back by reference, unchanged: the key travels beside it,
|
|
4857
|
+
* never folded into it.
|
|
4858
|
+
*/
|
|
4859
|
+
async loadManyKeyed(type, _options) {
|
|
4860
|
+
const typeStore = this.storage.get(type);
|
|
4861
|
+
if (!typeStore) return [];
|
|
4862
|
+
return Array.from(typeStore, ([name, data]) => ({ name, data }));
|
|
4863
|
+
}
|
|
4439
4864
|
async exists(type, name) {
|
|
4440
4865
|
return this.storage.get(type)?.has(name) ?? false;
|
|
4441
4866
|
}
|
|
@@ -4482,19 +4907,23 @@ var MemoryLoader = class {
|
|
|
4482
4907
|
|
|
4483
4908
|
// src/plugin.ts
|
|
4484
4909
|
import { DEFAULT_METADATA_TYPE_REGISTRY } from "@objectstack/spec/kernel";
|
|
4485
|
-
import { applyProtection } from "@objectstack/spec/shared";
|
|
4910
|
+
import { applyProtection as applyProtection2 } from "@objectstack/spec/shared";
|
|
4486
4911
|
import {
|
|
4487
|
-
SysMetadataObject as
|
|
4488
|
-
SysMetadataHistoryObject as
|
|
4912
|
+
SysMetadataObject as SysMetadataObject3,
|
|
4913
|
+
SysMetadataHistoryObject as SysMetadataHistoryObject3,
|
|
4489
4914
|
SysMetadataCommitObject,
|
|
4490
4915
|
SysMetadataAuditObject,
|
|
4491
|
-
SysViewDefinitionObject
|
|
4916
|
+
SysViewDefinitionObject,
|
|
4917
|
+
applyArtifactForwardConversions,
|
|
4918
|
+
detectUnboundFormViewPredicateRoots,
|
|
4919
|
+
BOUND_FORM_VIEW_PREDICATE_ROOTS,
|
|
4920
|
+
BOUND_FORM_FIELD_PREDICATE_ROOTS
|
|
4492
4921
|
} from "@objectstack/metadata-core";
|
|
4493
|
-
import { isAggregatedViewContainer, expandViewContainer } from "@objectstack/spec";
|
|
4494
4922
|
import { isAggregatedViewContainer as isAggregatedViewContainer2, expandViewContainer as expandViewContainer2 } from "@objectstack/spec";
|
|
4923
|
+
import { isAggregatedViewContainer as isAggregatedViewContainer3, expandViewContainer as expandViewContainer3 } from "@objectstack/spec";
|
|
4495
4924
|
var queryableMetadataObjects = [
|
|
4496
|
-
|
|
4497
|
-
|
|
4925
|
+
SysMetadataObject3,
|
|
4926
|
+
SysMetadataHistoryObject3,
|
|
4498
4927
|
// ADR-0067 commit log — sibling of sys_metadata_history (see note above).
|
|
4499
4928
|
SysMetadataCommitObject,
|
|
4500
4929
|
SysMetadataAuditObject,
|
|
@@ -4524,8 +4953,37 @@ var ARTIFACT_FIELD_TO_TYPE = {
|
|
|
4524
4953
|
// positions from artifact ingestion.
|
|
4525
4954
|
positions: "position",
|
|
4526
4955
|
permissions: "permission",
|
|
4956
|
+
// [ADR-0066 D1] `capabilities` reaches the door at #12892 step 1, the
|
|
4957
|
+
// maintainer's `option 1` ruling ("the door owns the registration
|
|
4958
|
+
// route" for the five artifact security collections). Until #12894
|
|
4959
|
+
// measured it, `AppPlugin`'s `SECURITY_FIELDS` block
|
|
4960
|
+
// (packages/runtime/src/app-plugin.ts) was this collection's SOLE
|
|
4961
|
+
// registrar on an artifact boot — the one security collection the door
|
|
4962
|
+
// could not reach — so a declared capability was registered from bytes
|
|
4963
|
+
// nothing strict-parses, with no schema default and no ADR-0010
|
|
4964
|
+
// provenance. Measured on the two-reader harness, the door's copy adds
|
|
4965
|
+
// exactly four keys the raw copy lacks: `scope` (the schema default)
|
|
4966
|
+
// and `_packageId` / `_packageVersion` / `_provenance`.
|
|
4967
|
+
//
|
|
4968
|
+
// ⚠️ This entry makes the door a SECOND writer, not yet the only one:
|
|
4969
|
+
// `AppPlugin` still registers `capabilities`, and it runs last, so the
|
|
4970
|
+
// raw copy still wins a real artifact boot. Step 2 of the ruling (that
|
|
4971
|
+
// block stops registering these five on the artifact path, after a
|
|
4972
|
+
// census of the non-artifact boot paths) is what makes this the only
|
|
4973
|
+
// copy. Until then the divergence is the interim reality the ruling
|
|
4974
|
+
// explicitly permits, and #12878's pins are what keep it visible.
|
|
4975
|
+
capabilities: "capability",
|
|
4527
4976
|
sharingRules: "sharing_rule",
|
|
4528
|
-
policies:
|
|
4977
|
+
// `policies: 'policy'` removed at #12894: the stack schema is a
|
|
4978
|
+
// `strictObject` that declares no top-level `policies` key, so a
|
|
4979
|
+
// definition carrying one is refused by the strict parse a few lines
|
|
4980
|
+
// below — the entry could never match, and nothing was ever registered
|
|
4981
|
+
// under `policy` from this map. The word is real, but it lives ONE LEVEL
|
|
4982
|
+
// DOWN: on a permission set it is an alias for `rowLevelSecurity`
|
|
4983
|
+
// (`PERMISSION_SET_KEY_ALIASES`, packages/spec/src/security/permission.zod.ts)
|
|
4984
|
+
// — a key on an ITEM, never a collection. Third retirement of this exact
|
|
4985
|
+
// shape in this map (`themes` and `roles` above); the reasons are kept
|
|
4986
|
+
// in place because the first two are what made this one findable.
|
|
4529
4987
|
apis: "api",
|
|
4530
4988
|
webhooks: "webhook",
|
|
4531
4989
|
agents: "agent",
|
|
@@ -4568,6 +5026,21 @@ var MetadataPlugin = class {
|
|
|
4568
5026
|
* degrades on purpose (objects are discovered via the legacy fallback).
|
|
4569
5027
|
*/
|
|
4570
5028
|
this.optionalDependencies = ["com.objectstack.engine.objectql"];
|
|
5029
|
+
/**
|
|
5030
|
+
* Once-per-process dedupe for the summaries the versioned artifact window
|
|
5031
|
+
* emits. The artifact watcher replays `_parseAndRegisterArtifact` on every
|
|
5032
|
+
* file change, so without this a dev loop over a legacy artifact would
|
|
5033
|
+
* re-announce the same finding on every reload — the same shape
|
|
5034
|
+
* `Protocol.storedConversionWarned` guards on the stored-row pass, which
|
|
5035
|
+
* this surfacing is modeled on.
|
|
5036
|
+
*
|
|
5037
|
+
* Two key families share the set, because they share the replay:
|
|
5038
|
+
* `<conversionId>|<label>` for a forward-conversion summary (#12772), and
|
|
5039
|
+
* `unbound-form-predicate-root|<label>` for the unbound-root notice
|
|
5040
|
+
* (#12915) — one line per artifact there, not one per conversion, since
|
|
5041
|
+
* the notice already aggregates every finding it made.
|
|
5042
|
+
*/
|
|
5043
|
+
this.artifactConversionWarned = /* @__PURE__ */ new Set();
|
|
4571
5044
|
this.init = async (ctx) => {
|
|
4572
5045
|
this.initCtx = ctx;
|
|
4573
5046
|
ctx.logger.info("Initializing Metadata Manager", {
|
|
@@ -4576,7 +5049,6 @@ var MetadataPlugin = class {
|
|
|
4576
5049
|
artifactSource: this.options.artifactSource?.mode
|
|
4577
5050
|
});
|
|
4578
5051
|
ctx.registerService("metadata", this.manager);
|
|
4579
|
-
console.log("[MetadataPlugin] Registered metadata service, has getRegisteredTypes:", typeof this.manager.getRegisteredTypes);
|
|
4580
5052
|
const registerSysObjects = this.options.registerSystemObjects !== false;
|
|
4581
5053
|
if (registerSysObjects) {
|
|
4582
5054
|
try {
|
|
@@ -4598,7 +5070,7 @@ var MetadataPlugin = class {
|
|
|
4598
5070
|
}
|
|
4599
5071
|
ctx.logger.info("MetadataPlugin providing metadata service (primary mode)", {
|
|
4600
5072
|
mode: this.options.artifactSource?.mode ?? "file-system",
|
|
4601
|
-
features: ["watch", "multi-format", "query", "
|
|
5073
|
+
features: ["watch", "multi-format", "query", "type-registry"]
|
|
4602
5074
|
});
|
|
4603
5075
|
};
|
|
4604
5076
|
this.start = async (ctx) => {
|
|
@@ -4681,7 +5153,7 @@ var MetadataPlugin = class {
|
|
|
4681
5153
|
if (httpServer && typeof httpServer.getRawApp === "function") {
|
|
4682
5154
|
const { registerMetadataHmrRoutes: registerMetadataHmrRoutes2 } = await Promise.resolve().then(() => (init_hmr_routes(), hmr_routes_exports));
|
|
4683
5155
|
const hub = registerMetadataHmrRoutes2(httpServer.getRawApp(), this.manager);
|
|
4684
|
-
hub
|
|
5156
|
+
hub?.setOnPostReload(async (body = {}) => {
|
|
4685
5157
|
const src3 = this.options.artifactSource;
|
|
4686
5158
|
if (src3?.mode === "local-file") {
|
|
4687
5159
|
try {
|
|
@@ -4721,7 +5193,7 @@ var MetadataPlugin = class {
|
|
|
4721
5193
|
pending = true;
|
|
4722
5194
|
try {
|
|
4723
5195
|
await this._reloadAndAnnounce(ctx, src2, [src2.path]);
|
|
4724
|
-
hub
|
|
5196
|
+
hub?.broadcastReload("artifact-file-changed", [src2.path]);
|
|
4725
5197
|
ctx.logger.info("[MetadataPlugin] artifact auto-reloaded (file watcher)", {
|
|
4726
5198
|
path: src2.path
|
|
4727
5199
|
});
|
|
@@ -4743,7 +5215,13 @@ var MetadataPlugin = class {
|
|
|
4743
5215
|
ctx.logger.warn("[MetadataPlugin] artifact watcher failed to start", { error: e?.message });
|
|
4744
5216
|
}
|
|
4745
5217
|
}
|
|
4746
|
-
|
|
5218
|
+
if (hub) {
|
|
5219
|
+
console.log("[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events");
|
|
5220
|
+
} else {
|
|
5221
|
+
console.log(
|
|
5222
|
+
`[MetadataPlugin] dev metadata-HMR endpoints NOT mounted \u2014 they require NODE_ENV=development (this process: ${process.env.NODE_ENV ? `NODE_ENV=${process.env.NODE_ENV}` : "NODE_ENV unset, treated as production"})`
|
|
5223
|
+
);
|
|
5224
|
+
}
|
|
4747
5225
|
} else {
|
|
4748
5226
|
console.log("[MetadataPlugin] HTTP server with getRawApp() not available \u2014 skipping HMR endpoint");
|
|
4749
5227
|
}
|
|
@@ -4839,6 +5317,106 @@ var MetadataPlugin = class {
|
|
|
4839
5317
|
if (timer) clearTimeout(timer);
|
|
4840
5318
|
}
|
|
4841
5319
|
}
|
|
5320
|
+
/**
|
|
5321
|
+
* Versioned ADR-0087 forward conversion at the artifact-ingestion door
|
|
5322
|
+
* (#12772) — runs BEFORE the strict schema parse below, because the parse
|
|
5323
|
+
* is the refusal point.
|
|
5324
|
+
*
|
|
5325
|
+
* A compiled artifact is data at rest with a version stamp: built by
|
|
5326
|
+
* released tooling, then unchanged while the platform moves on. When a
|
|
5327
|
+
* spec release retires an authorable key inside a protocol line (spec
|
|
5328
|
+
* 17.1 → 17.2 retired the `allowRestore`/`allowPurge` permission bits),
|
|
5329
|
+
* every already-built artifact carrying the key becomes unbootable at the
|
|
5330
|
+
* tombstone — with no operator remedy, since `os migrate meta` targets
|
|
5331
|
+
* sources, not built artifacts. The stored-row read path already replays
|
|
5332
|
+
* the conversion chain for exactly this reason
|
|
5333
|
+
* (`applyConversionsToStoredItem`, ADR-0087 addendum); this is the same
|
|
5334
|
+
* policy at the artifact door, **keyed off the artifact's own declared
|
|
5335
|
+
* `engines.protocol` floor**: an artifact authored below the running spec
|
|
5336
|
+
* version converts forward, an artifact authored at the current (or a
|
|
5337
|
+
* newer) surface converts nothing and answers to the strict parse,
|
|
5338
|
+
* tombstones included. The version key is what keeps this a conversion
|
|
5339
|
+
* rather than an amnesty — the retired keys return with the M2 lifecycle
|
|
5340
|
+
* batch (#1883), and artifacts authored against that surface must never
|
|
5341
|
+
* have them stripped by history.
|
|
5342
|
+
*
|
|
5343
|
+
* Notices surface the way the stored-row pass's do — operator-visible and
|
|
5344
|
+
* deduped — as one summary line per conversion per artifact rather than
|
|
5345
|
+
* one per rewritten path (a real 17.1 artifact carried 150 strips of the
|
|
5346
|
+
* same two keys; 150 identical warn lines would bury the boot log).
|
|
5347
|
+
*/
|
|
5348
|
+
_convertArtifactForward(ctx, definition, label) {
|
|
5349
|
+
const result = applyArtifactForwardConversions(definition);
|
|
5350
|
+
this._warnUnboundFormPredicateRoots(ctx, result, label);
|
|
5351
|
+
if (result.notices.length === 0) return result.definition;
|
|
5352
|
+
const byConversion = /* @__PURE__ */ new Map();
|
|
5353
|
+
for (const n of result.notices) {
|
|
5354
|
+
const existing = byConversion.get(n.conversionId);
|
|
5355
|
+
if (existing) existing.count += 1;
|
|
5356
|
+
else byConversion.set(n.conversionId, { count: 1, firstPath: n.path, message: n.message });
|
|
5357
|
+
}
|
|
5358
|
+
for (const [conversionId, agg] of byConversion) {
|
|
5359
|
+
const key = `${conversionId}|${label}`;
|
|
5360
|
+
if (this.artifactConversionWarned.has(key)) continue;
|
|
5361
|
+
this.artifactConversionWarned.add(key);
|
|
5362
|
+
ctx.logger.warn(
|
|
5363
|
+
`[MetadataPlugin] artifact '${label}' predates this runtime's spec (authored engines.protocol floor ${result.authoredFloor ?? "<undeclared>"}, runtime spec ${result.runtimeSpecVersion}) \u2014 converted ${agg.count} site(s) forward via ADR-0087 conversion '${conversionId}' (first at ${agg.firstPath}). ${agg.message} The artifact file itself is unchanged \u2014 rebuild it with current tooling ('os build') to persist the canonical shape.`
|
|
5364
|
+
);
|
|
5365
|
+
}
|
|
5366
|
+
return result.definition;
|
|
5367
|
+
}
|
|
5368
|
+
/**
|
|
5369
|
+
* Operator-facing boot notice for form-view predicates that fault OPEN on
|
|
5370
|
+
* this runtime (#12915 scope C — maintainer ruling 2026-08-28, 「同意C」).
|
|
5371
|
+
*
|
|
5372
|
+
* A form-view predicate binds `record` / `previous` / `parent` (runtime
|
|
5373
|
+
* record forms) or `data` (metadata-editing forms) — and a FIELD-level one
|
|
5374
|
+
* also binds `current_user` and its ADR-0068 aliases (objectui#6010),
|
|
5375
|
+
* which a SECTION-level one does not. The contract states beside that
|
|
5376
|
+
* vocabulary that a bare identifier is UNBOUND and the predicate faults,
|
|
5377
|
+
* and `visibleWhen`'s fault fallback is `true`. On a real
|
|
5378
|
+
* 17.1-built artifact that combination dead-ends record creation in the
|
|
5379
|
+
* console: the conditionally hidden field renders, and its unconditional
|
|
5380
|
+
* `required: true` — authored to be gated by the visibility that no longer
|
|
5381
|
+
* applies — blocks every submit, while the same payload POSTs 201 through
|
|
5382
|
+
* REST. Nothing refused, nothing logged, and only the operator can fix it
|
|
5383
|
+
* (by rebuilding the artifact), so this is the channel the ruling picked:
|
|
5384
|
+
* service startup, server-side, never a console surface — the person at
|
|
5385
|
+
* the form cannot act on "your artifact is stale".
|
|
5386
|
+
*
|
|
5387
|
+
* **Detection only.** No refusal, no rewrite, no behaviour change: the
|
|
5388
|
+
* predicate keeps faulting open exactly as before. Rewriting a bare root to
|
|
5389
|
+
* `record.` is the ADR-0087 conversion (#12915 scope A), deferred by the
|
|
5390
|
+
* same ruling with an explicit start line.
|
|
5391
|
+
*
|
|
5392
|
+
* **Same versioned window as the conversion replay above** — and read off
|
|
5393
|
+
* that pass's own verdict rather than recomputed, so the two can never
|
|
5394
|
+
* disagree about which artifacts are "old". An artifact declaring the
|
|
5395
|
+
* current (or a newer) floor answers to the strict parse and gets nothing
|
|
5396
|
+
* from here even when it does carry bare roots; that boundary is what keeps
|
|
5397
|
+
* a notice about legacy artifacts out of contract territory. An undeclared
|
|
5398
|
+
* range is treated as old data at rest, matching the grandfathering posture
|
|
5399
|
+
* the window already takes (`converted-undeclared`).
|
|
5400
|
+
*/
|
|
5401
|
+
_warnUnboundFormPredicateRoots(ctx, result, label) {
|
|
5402
|
+
if (result.verdict !== "converted-forward" && result.verdict !== "converted-undeclared") return;
|
|
5403
|
+
const findings = detectUnboundFormViewPredicateRoots(result.definition);
|
|
5404
|
+
if (findings.length === 0) return;
|
|
5405
|
+
const key = `unbound-form-predicate-root|${label}`;
|
|
5406
|
+
if (this.artifactConversionWarned.has(key)) return;
|
|
5407
|
+
this.artifactConversionWarned.add(key);
|
|
5408
|
+
const views = [...new Set(findings.map((f) => f.view))];
|
|
5409
|
+
const roots = [...new Set(findings.map((f) => f.root))];
|
|
5410
|
+
const quote = (list) => list.map((v) => `'${v}'`).join(", ");
|
|
5411
|
+
const surfaces = new Set(findings.map((f) => f.surface));
|
|
5412
|
+
const vocabulary = [
|
|
5413
|
+
surfaces.has("field") ? `on a form FIELD: ${quote(BOUND_FORM_FIELD_PREDICATE_ROOTS)}` : null,
|
|
5414
|
+
surfaces.has("section") ? `on a form SECTION: ${quote(BOUND_FORM_VIEW_PREDICATE_ROOTS)}` : null
|
|
5415
|
+
].filter(Boolean).join("; ");
|
|
5416
|
+
ctx.logger.warn(
|
|
5417
|
+
`[MetadataPlugin] artifact '${label}' predates this runtime's spec (authored engines.protocol floor ${result.authoredFloor ?? "<undeclared>"}, runtime spec ${result.runtimeSpecVersion}) and carries ${findings.length} form-view predicate(s) whose root identifier is NOT bound where it evaluates \u2014 ${quote(roots)} (bound roots ${vocabulary}) \u2014 across ${views.length} view(s): ${views.join(", ")} (first at ${findings[0].path}). Each one faults at evaluation and visibility fails OPEN, so a field the predicate was authored to HIDE renders anyway \u2014 and an unconditional 'required: true' on such a field dead-ends record creation in the console while the REST door still accepts it. Nothing was rewritten and no behaviour changed here; rebuild the artifact with current tooling ('os build') so its predicates carry a bound root ('record.<field>').`
|
|
5418
|
+
);
|
|
5419
|
+
}
|
|
4842
5420
|
/**
|
|
4843
5421
|
* Parse raw artifact JSON (envelope or bare definition) and register all
|
|
4844
5422
|
* metadata items into the MetadataManager.
|
|
@@ -4857,16 +5435,22 @@ var MetadataPlugin = class {
|
|
|
4857
5435
|
let metadata;
|
|
4858
5436
|
const obj = raw;
|
|
4859
5437
|
if (obj?.schemaVersion && obj?.commitId && obj?.metadata !== void 0) {
|
|
4860
|
-
const artifact = EnvironmentArtifactSchema.parse(
|
|
5438
|
+
const artifact = EnvironmentArtifactSchema.parse({
|
|
5439
|
+
...obj,
|
|
5440
|
+
metadata: this._convertArtifactForward(ctx, obj.metadata, label)
|
|
5441
|
+
});
|
|
4861
5442
|
metadata = artifact.metadata;
|
|
4862
5443
|
} else if (obj?.success && obj?.data?.metadata) {
|
|
4863
|
-
const artifact = EnvironmentArtifactSchema.parse(
|
|
5444
|
+
const artifact = EnvironmentArtifactSchema.parse({
|
|
5445
|
+
...obj.data,
|
|
5446
|
+
metadata: this._convertArtifactForward(ctx, obj.data.metadata, label)
|
|
5447
|
+
});
|
|
4864
5448
|
metadata = artifact.metadata;
|
|
4865
5449
|
} else {
|
|
4866
|
-
const def = ObjectStackDefinitionSchema.parse(obj);
|
|
5450
|
+
const def = ObjectStackDefinitionSchema.parse(this._convertArtifactForward(ctx, obj, label));
|
|
4867
5451
|
const canonical = JSON.stringify(def, Object.keys(def).sort());
|
|
4868
5452
|
const checksum = createHash2("sha256").update(canonical).digest("hex");
|
|
4869
|
-
const environmentId = this.options.environmentId ?? "
|
|
5453
|
+
const environmentId = this.options.environmentId ?? "env_local";
|
|
4870
5454
|
EnvironmentArtifactSchema.parse({
|
|
4871
5455
|
schemaVersion: "0.1",
|
|
4872
5456
|
environmentId,
|
|
@@ -4880,53 +5464,127 @@ var MetadataPlugin = class {
|
|
|
4880
5464
|
const memLoader = new MemoryLoader();
|
|
4881
5465
|
const manifestPackageId = metadata?.manifest?.id ?? metadata?.id ?? void 0;
|
|
4882
5466
|
const manifestVersion = metadata?.manifest?.version ?? metadata?.version ?? void 0;
|
|
5467
|
+
const carriesPackages = Array.isArray(metadata?.packages);
|
|
5468
|
+
const bodies = resolveArtifactPackageOrder(metadata);
|
|
5469
|
+
const ownedByPackage = /* @__PURE__ */ new Map();
|
|
5470
|
+
const claim = (type, name) => {
|
|
5471
|
+
let names = ownedByPackage.get(type);
|
|
5472
|
+
if (!names) ownedByPackage.set(type, names = /* @__PURE__ */ new Set());
|
|
5473
|
+
names.add(name);
|
|
5474
|
+
};
|
|
5475
|
+
const claimed = (type, name) => ownedByPackage.get(type)?.has(name) === true;
|
|
5476
|
+
let totalRegistered = 0;
|
|
5477
|
+
for (const body of bodies) {
|
|
5478
|
+
totalRegistered += await this._registerArtifactBodyCollections(
|
|
5479
|
+
ctx,
|
|
5480
|
+
memLoader,
|
|
5481
|
+
body,
|
|
5482
|
+
carriesPackages ? {
|
|
5483
|
+
packageId: artifactPackageId(body),
|
|
5484
|
+
packageVersion: body?.version ?? void 0
|
|
5485
|
+
} : { packageId: manifestPackageId, packageVersion: manifestVersion },
|
|
5486
|
+
{ claim: carriesPackages ? claim : void 0 }
|
|
5487
|
+
);
|
|
5488
|
+
}
|
|
5489
|
+
if (carriesPackages) {
|
|
5490
|
+
const residual = await this._registerArtifactBodyCollections(
|
|
5491
|
+
ctx,
|
|
5492
|
+
memLoader,
|
|
5493
|
+
metadata,
|
|
5494
|
+
{ packageId: manifestPackageId, packageVersion: manifestVersion },
|
|
5495
|
+
{ skip: claimed }
|
|
5496
|
+
);
|
|
5497
|
+
totalRegistered += residual;
|
|
5498
|
+
if (residual > 0) {
|
|
5499
|
+
ctx.logger.warn(
|
|
5500
|
+
`[MetadataPlugin] artifact '${label}' carries ${residual} top-level metadata item(s) that none of its ${bodies.length} package bodies declare. They were registered under the artifact's own manifest id ('${manifestPackageId ?? "<none>"}') 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.`
|
|
5501
|
+
);
|
|
5502
|
+
}
|
|
5503
|
+
}
|
|
5504
|
+
this.manager.registerLoader(memLoader);
|
|
5505
|
+
ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
|
|
5506
|
+
return totalRegistered;
|
|
5507
|
+
}
|
|
5508
|
+
/**
|
|
5509
|
+
* Register ONE artifact body's collections into the MetadataManager.
|
|
5510
|
+
*
|
|
5511
|
+
* A "body" is either the whole artifact (the single-package branch, where
|
|
5512
|
+
* the artifact and its one package are the same object) or one entry of
|
|
5513
|
+
* `packages[]` (ADR-0130 D4), which is an assembled
|
|
5514
|
+
* `{ ...manifest, ...collections }` payload carrying the same collection
|
|
5515
|
+
* keys the top level does. The loop is identical for both — that is the
|
|
5516
|
+
* point: there is one ingestion of a collection here, not one per shape.
|
|
5517
|
+
*
|
|
5518
|
+
* @param provenance - The `(packageId, packageVersion)` every item found in
|
|
5519
|
+
* this body is stamped with (ADR-0010 §3.7, via `applyProtection`). It is
|
|
5520
|
+
* the body's OWN identity, never the enclosing artifact's, which is what
|
|
5521
|
+
* makes a multi-package artifact's items agree with the registry and with
|
|
5522
|
+
* `GET /api/v1/packages` about who owns them.
|
|
5523
|
+
* @param slots.claim - Called with every `(type, name)` this pass
|
|
5524
|
+
* registered. Passed when reading package bodies; the residual sweep uses
|
|
5525
|
+
* what it recorded.
|
|
5526
|
+
* @param slots.skip - Consulted before registering each `(type, name)`.
|
|
5527
|
+
* Passed ONLY by the residual sweep, so a package body's copy is never
|
|
5528
|
+
* overwritten by the flattened top-level copy of the same definition —
|
|
5529
|
+
* the overwrite that re-attributed the item to the artifact's manifest.
|
|
5530
|
+
* ⛔ It is never passed while reading the bodies themselves: two items of
|
|
5531
|
+
* one name inside one body still register as they always have (last
|
|
5532
|
+
* wins), because suppressing that would be a behaviour change on the
|
|
5533
|
+
* single-package branch D7 pins.
|
|
5534
|
+
* @returns How many items this body registered.
|
|
5535
|
+
*/
|
|
5536
|
+
async _registerArtifactBodyCollections(ctx, memLoader, body, provenance, slots = {}) {
|
|
5537
|
+
const { packageId, packageVersion } = provenance;
|
|
4883
5538
|
let totalRegistered = 0;
|
|
4884
5539
|
for (const [field, metaType] of Object.entries(ARTIFACT_FIELD_TO_TYPE)) {
|
|
4885
|
-
const items =
|
|
5540
|
+
const items = body[field];
|
|
4886
5541
|
if (!Array.isArray(items) || items.length === 0) continue;
|
|
4887
5542
|
for (const item of items) {
|
|
4888
|
-
if (metaType === "view" &&
|
|
4889
|
-
const viewObject = item
|
|
5543
|
+
if (metaType === "view" && isAggregatedViewContainer3(item)) {
|
|
5544
|
+
const viewObject = deriveViewContainerObject(item);
|
|
4890
5545
|
if (!viewObject) continue;
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
5546
|
+
if (slots.skip?.("view", viewObject)) continue;
|
|
5547
|
+
applyProtection2(item, {
|
|
5548
|
+
packageId,
|
|
5549
|
+
packageVersion
|
|
4894
5550
|
});
|
|
4895
5551
|
await memLoader.save("view", viewObject, item);
|
|
4896
5552
|
await this.manager.register("view", viewObject, item, { notify: false });
|
|
4897
5553
|
totalRegistered++;
|
|
4898
|
-
|
|
5554
|
+
slots.claim?.("view", viewObject);
|
|
5555
|
+
for (const vi of expandViewContainer3(viewObject, item)) {
|
|
4899
5556
|
for (const w of vi._diagnostics?.warnings ?? []) {
|
|
4900
5557
|
ctx.logger.warn(`[MetadataPlugin] View expansion warning for '${vi.name}': ${w.message}`);
|
|
4901
5558
|
}
|
|
4902
|
-
|
|
4903
|
-
packageId
|
|
4904
|
-
packageVersion
|
|
5559
|
+
applyProtection2(vi, {
|
|
5560
|
+
packageId,
|
|
5561
|
+
packageVersion
|
|
4905
5562
|
});
|
|
4906
5563
|
await memLoader.save("view", vi.name, vi);
|
|
4907
5564
|
await this.manager.register("view", vi.name, vi, { notify: false });
|
|
4908
5565
|
totalRegistered++;
|
|
5566
|
+
slots.claim?.("view", vi.name);
|
|
4909
5567
|
}
|
|
4910
5568
|
continue;
|
|
4911
5569
|
}
|
|
4912
5570
|
let name = item?.name;
|
|
4913
5571
|
if (!name) {
|
|
4914
5572
|
if (metaType === "view") {
|
|
4915
|
-
name = item
|
|
5573
|
+
name = deriveViewContainerObject(item);
|
|
4916
5574
|
}
|
|
4917
5575
|
}
|
|
4918
5576
|
if (!name) continue;
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
5577
|
+
if (slots.skip?.(metaType, name)) continue;
|
|
5578
|
+
applyProtection2(item, {
|
|
5579
|
+
packageId,
|
|
5580
|
+
packageVersion
|
|
4922
5581
|
});
|
|
4923
5582
|
await memLoader.save(metaType, name, item);
|
|
4924
5583
|
await this.manager.register(metaType, name, item, { notify: false });
|
|
4925
5584
|
totalRegistered++;
|
|
5585
|
+
slots.claim?.(metaType, name);
|
|
4926
5586
|
}
|
|
4927
5587
|
}
|
|
4928
|
-
this.manager.registerLoader(memLoader);
|
|
4929
|
-
ctx.logger.info("[MetadataPlugin] Artifact metadata loaded", { source: label, totalRegistered });
|
|
4930
5588
|
return totalRegistered;
|
|
4931
5589
|
}
|
|
4932
5590
|
/**
|
|
@@ -5002,7 +5660,7 @@ var MetadataPlugin = class {
|
|
|
5002
5660
|
for (const item of items) {
|
|
5003
5661
|
const meta = item;
|
|
5004
5662
|
if (meta?.name) {
|
|
5005
|
-
|
|
5663
|
+
applyProtection2(meta, {
|
|
5006
5664
|
packageId: this.options.packageId
|
|
5007
5665
|
});
|
|
5008
5666
|
await this.manager.register(entry.type, meta.name, item, { notify: false });
|
|
@@ -5098,9 +5756,43 @@ var RemoteLoader = class {
|
|
|
5098
5756
|
format: "json"
|
|
5099
5757
|
};
|
|
5100
5758
|
}
|
|
5759
|
+
/**
|
|
5760
|
+
* [#15037] Report only the names that ARE names.
|
|
5761
|
+
*
|
|
5762
|
+
* This read used to be `loadMany<{ name: string }>(type)` mapped straight to
|
|
5763
|
+
* `items.map(i => i.name)`. That type argument is an ASSERTION about bodies
|
|
5764
|
+
* that arrived over HTTP, and nothing checked it: a body with no top-level
|
|
5765
|
+
* `name` yielded `undefined`, which went into an array this signature
|
|
5766
|
+
* declares as `string[]` and reached consumers through
|
|
5767
|
+
* `MetadataManager.listNames()` — a runtime violation of a declared type,
|
|
5768
|
+
* not an untidy entry. A consumer that keys by it, lower-cases it, or feeds
|
|
5769
|
+
* it back to a by-name `load()` gets `undefined` where the type says it
|
|
5770
|
+
* cannot be.
|
|
5771
|
+
*
|
|
5772
|
+
* The guard is `DatabaseLoader.list()`'s, one file away: same cast-then-map
|
|
5773
|
+
* spelling, one `typeof` filter behind it. Silently dropping is the landed
|
|
5774
|
+
* direction, not a preference — `DatabaseLoader` drops rather than throws,
|
|
5775
|
+
* and `FilesystemLoader`'s narrowing carries a maintainer ruling (via the
|
|
5776
|
+
* director seat on #14486, 2026-09-02) that chose narrowing (A) over
|
|
5777
|
+
* refusing loudly (B), because a name in the list that the door answers
|
|
5778
|
+
* `null` for is the silent failure an author reads as their own typo. An
|
|
5779
|
+
* `undefined` here is the extreme form of that name.
|
|
5780
|
+
*
|
|
5781
|
+
* ⛔ NOT copied from the siblings: `MemoryLoader` answers with its store
|
|
5782
|
+
* keys, and #14205 ruled that identity is the key the store holds an item
|
|
5783
|
+
* under rather than `body.name`. This loader reads over HTTP and holds no
|
|
5784
|
+
* store key, so `body.name` is the only identity it has — the list is
|
|
5785
|
+
* narrowed to agree with the door instead. `loadMany()` is deliberately
|
|
5786
|
+
* untouched: it keys nothing, so a nameless body is still served there.
|
|
5787
|
+
*
|
|
5788
|
+
* The predicate is spelled as a type guard, and the mapped element type left
|
|
5789
|
+
* `unknown`, so `tsc` PROVES the declared `string[]` instead of a cast
|
|
5790
|
+
* asserting it — otherwise the compiler reads the filter as always-true and
|
|
5791
|
+
* a later reader deletes it as dead.
|
|
5792
|
+
*/
|
|
5101
5793
|
async list(type) {
|
|
5102
5794
|
const items = await this.loadMany(type);
|
|
5103
|
-
return items.map((
|
|
5795
|
+
return items.map((item) => item.name).filter((name) => typeof name === "string");
|
|
5104
5796
|
}
|
|
5105
5797
|
async save(type, name, data, _options) {
|
|
5106
5798
|
const response = await fetch(`${this.baseUrl}/${type}/${name}`, {
|
|
@@ -5120,7 +5812,7 @@ var RemoteLoader = class {
|
|
|
5120
5812
|
};
|
|
5121
5813
|
|
|
5122
5814
|
// src/index.ts
|
|
5123
|
-
import { SysMetadataObject as
|
|
5815
|
+
import { SysMetadataObject as SysMetadataObject4, SysMetadataHistoryObject as SysMetadataHistoryObject4 } from "@objectstack/metadata-core";
|
|
5124
5816
|
|
|
5125
5817
|
// src/utils/history-cleanup.ts
|
|
5126
5818
|
import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2 } from "@objectstack/spec/kernel";
|
|
@@ -5140,9 +5832,9 @@ var HistoryCleanupManager = class {
|
|
|
5140
5832
|
return;
|
|
5141
5833
|
}
|
|
5142
5834
|
const intervalMs = (this.policy.cleanupIntervalHours ?? 24) * 60 * 60 * 1e3;
|
|
5143
|
-
void this
|
|
5835
|
+
void runCleanupAndReport(this);
|
|
5144
5836
|
this.cleanupTimer = setInterval(() => {
|
|
5145
|
-
void this
|
|
5837
|
+
void runCleanupAndReport(this);
|
|
5146
5838
|
}, intervalMs);
|
|
5147
5839
|
}
|
|
5148
5840
|
/**
|
|
@@ -5169,7 +5861,7 @@ var HistoryCleanupManager = class {
|
|
|
5169
5861
|
try {
|
|
5170
5862
|
if (this.policy.maxAgeDays) {
|
|
5171
5863
|
const cutoffDate = /* @__PURE__ */ new Date();
|
|
5172
|
-
cutoffDate.
|
|
5864
|
+
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
|
|
5173
5865
|
const cutoffISO = cutoffDate.toISOString();
|
|
5174
5866
|
const filter = {
|
|
5175
5867
|
recorded_at: { $lt: cutoffISO }
|
|
@@ -5289,7 +5981,7 @@ var HistoryCleanupManager = class {
|
|
|
5289
5981
|
if (organizationId) baseWhere.organization_id = organizationId;
|
|
5290
5982
|
if (this.policy.maxAgeDays) {
|
|
5291
5983
|
const cutoffDate = /* @__PURE__ */ new Date();
|
|
5292
|
-
cutoffDate.
|
|
5984
|
+
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays);
|
|
5293
5985
|
const cutoffISO = cutoffDate.toISOString();
|
|
5294
5986
|
const filter = {
|
|
5295
5987
|
recorded_at: { $lt: cutoffISO },
|
|
@@ -5336,6 +6028,23 @@ var HistoryCleanupManager = class {
|
|
|
5336
6028
|
};
|
|
5337
6029
|
}
|
|
5338
6030
|
};
|
|
6031
|
+
async function runCleanupAndReport(manager) {
|
|
6032
|
+
let outcome;
|
|
6033
|
+
try {
|
|
6034
|
+
outcome = await manager.runCleanup();
|
|
6035
|
+
} catch (error) {
|
|
6036
|
+
console.error(
|
|
6037
|
+
"History cleanup: the run did not complete, so no history row past the retention policy was deleted and the table keeps growing while the system reports healthy. Fix: the cause below comes from the configured data driver, not from the retention policy; call `runCleanup()` directly to reproduce it. Cause:",
|
|
6038
|
+
error
|
|
6039
|
+
);
|
|
6040
|
+
return;
|
|
6041
|
+
}
|
|
6042
|
+
if (outcome.errors > 0) {
|
|
6043
|
+
console.error(
|
|
6044
|
+
`History cleanup: ${outcome.errors} delete operation(s) failed and ${outcome.deleted} row(s) were deleted. The history rows those deletes were meant to remove are still in the table, nothing retries them, and the table grows past the retention policy while the system keeps reporting healthy. Fix: check the data driver delete path for the metadata history table. The per-failure causes are not carried out of \`runCleanup()\`, so reproduce them against the driver directly.`
|
|
6045
|
+
);
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
5339
6048
|
|
|
5340
6049
|
// src/migration/index.ts
|
|
5341
6050
|
var migration_exports = {};
|
|
@@ -5393,6 +6102,9 @@ var MigrationExecutor = class {
|
|
|
5393
6102
|
}
|
|
5394
6103
|
};
|
|
5395
6104
|
export {
|
|
6105
|
+
AMBIGUOUS_METADATA_STEM_CODE,
|
|
6106
|
+
AMBIGUOUS_METADATA_STEM_STATUS,
|
|
6107
|
+
AmbiguousMetadataStemError,
|
|
5396
6108
|
DatabaseLoader,
|
|
5397
6109
|
FilesystemLoader,
|
|
5398
6110
|
HistoryCleanupManager,
|
|
@@ -5403,12 +6115,14 @@ export {
|
|
|
5403
6115
|
migration_exports as Migration,
|
|
5404
6116
|
NodeMetadataManager,
|
|
5405
6117
|
RemoteLoader,
|
|
5406
|
-
|
|
5407
|
-
|
|
6118
|
+
SysMetadataHistoryObject4 as SysMetadataHistoryObject,
|
|
6119
|
+
SysMetadataObject4 as SysMetadataObject,
|
|
5408
6120
|
TypeScriptSerializer,
|
|
5409
6121
|
YAMLSerializer,
|
|
5410
6122
|
calculateChecksum,
|
|
6123
|
+
deriveViewContainerObject,
|
|
5411
6124
|
generateDiffSummary,
|
|
5412
|
-
generateSimpleDiff
|
|
6125
|
+
generateSimpleDiff,
|
|
6126
|
+
isAmbiguousMetadataStemError
|
|
5413
6127
|
};
|
|
5414
6128
|
//# sourceMappingURL=node.js.map
|