@objectstack/metadata 17.0.0-rc.5 → 17.0.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 +3034 -0
- package/dist/errors.cjs +56 -2
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.d.cts +4 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +56 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.cjs +307 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -17
- package/dist/index.d.ts +108 -17
- package/dist/index.js +312 -93
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.cjs +0 -44
- package/dist/migrations/index.cjs.map +1 -1
- package/dist/migrations/index.d.cts +1 -38
- package/dist/migrations/index.d.ts +1 -38
- package/dist/migrations/index.js +0 -43
- package/dist/migrations/index.js.map +1 -1
- package/dist/node.cjs +307 -92
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +312 -93
- package/dist/node.js.map +1 -1
- package/package.json +8 -8
package/dist/node.cjs
CHANGED
|
@@ -555,6 +555,7 @@ var LRUCache = class {
|
|
|
555
555
|
};
|
|
556
556
|
|
|
557
557
|
// src/utils/schema-sync-errors.ts
|
|
558
|
+
var import_types = require("@objectstack/types");
|
|
558
559
|
var ALREADY_EXISTS = {
|
|
559
560
|
codes: /* @__PURE__ */ new Set([
|
|
560
561
|
// PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)
|
|
@@ -598,14 +599,67 @@ var MISSING_TABLE = {
|
|
|
598
599
|
* - PostgreSQL: `relation "sys_metadata_history" does not exist`
|
|
599
600
|
* - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
|
|
600
601
|
*/
|
|
601
|
-
message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i
|
|
602
|
+
message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
|
|
603
|
+
excludes: {
|
|
604
|
+
/**
|
|
605
|
+
* Exactly the three SQLSTATEs the docblock above already names as
|
|
606
|
+
* must-stay-loud neighbours of `does not exist`. They are listed here
|
|
607
|
+
* rather than merely trusted to miss the message test, because two of
|
|
608
|
+
* them (42703 columns, 42704 constraints/triggers) have a phrasing that
|
|
609
|
+
* *does* hit it, and because a code is a fact where prose is a guess.
|
|
610
|
+
*
|
|
611
|
+
* Postgres-shaped on purpose: measured, neither MySQL
|
|
612
|
+
* (`Unknown column 'label' in 'field list'`) nor SQLite
|
|
613
|
+
* (`no such column: bogus`, `table t has no column named label`)
|
|
614
|
+
* phrases a sub-object failure so that a missing-table phrase falls out
|
|
615
|
+
* of it, so there is nothing there to exclude. Adding their codes would
|
|
616
|
+
* be surface with no defect behind it.
|
|
617
|
+
*/
|
|
618
|
+
codes: /* @__PURE__ */ new Set([
|
|
619
|
+
"42703",
|
|
620
|
+
// undefined_column
|
|
621
|
+
"42704",
|
|
622
|
+
// undefined_object — constraint, trigger, role, type, …
|
|
623
|
+
"3D000"
|
|
624
|
+
// invalid_catalog_name — `database "x" does not exist`
|
|
625
|
+
]),
|
|
626
|
+
/**
|
|
627
|
+
* `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
|
|
628
|
+
* failure about something *inside* a relation, which therefore says the
|
|
629
|
+
* relation itself is present. The two in-repo siblings that carry this
|
|
630
|
+
* phrase are `mapDataError` (`packages/rest`, #5352) and
|
|
631
|
+
* `service-analytics`'s missing-column subtraction (#6035/PR #6346).
|
|
632
|
+
*
|
|
633
|
+
* [#6615] All three now read one home — `@objectstack/types` — instead
|
|
634
|
+
* of three hand-kept copies, so the phrase can no longer be taught to
|
|
635
|
+
* the repo a fourth time or drift in one package only. The **width**
|
|
636
|
+
* difference that used to justify the copy is preserved and is the
|
|
637
|
+
* reason the home exports two functions rather than one: those two
|
|
638
|
+
* *extract* the column name to phrase a better error, so a miss costs a
|
|
639
|
+
* vaguer message; this one *excludes*, so a miss restores the
|
|
640
|
+
* corruption. {@link isRelationSubObjectPhrase} is therefore the wider
|
|
641
|
+
* question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
|
|
642
|
+
* anchors: any sub-object, any quoted identifier, any verdict.
|
|
643
|
+
* Over-matching here only ever converts a benign verdict into a loud
|
|
644
|
+
* one, which is the direction this whole module already errs in.
|
|
645
|
+
*/
|
|
646
|
+
matchesMessage: import_types.isRelationSubObjectPhrase
|
|
647
|
+
}
|
|
602
648
|
};
|
|
603
649
|
var MAX_CAUSE_DEPTH = 4;
|
|
604
650
|
function matchesDriverError(error, signature, depth) {
|
|
605
651
|
if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
|
|
606
|
-
if (typeof error === "string")
|
|
652
|
+
if (typeof error === "string") {
|
|
653
|
+
if (signature.excludes?.matchesMessage(error)) return false;
|
|
654
|
+
return signature.message.test(error);
|
|
655
|
+
}
|
|
607
656
|
if (typeof error !== "object") return false;
|
|
608
657
|
const err = error;
|
|
658
|
+
const excludes = signature.excludes;
|
|
659
|
+
if (excludes) {
|
|
660
|
+
if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
|
|
661
|
+
if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
|
|
662
|
+
}
|
|
609
663
|
if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
|
|
610
664
|
if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
|
|
611
665
|
if (typeof err.message === "string" && signature.message.test(err.message)) return true;
|
|
@@ -618,48 +672,6 @@ function isMissingTableError(error, depth = 0) {
|
|
|
618
672
|
return matchesDriverError(error, MISSING_TABLE, depth);
|
|
619
673
|
}
|
|
620
674
|
|
|
621
|
-
// src/migrations/add-sys-metadata-overlay-index.ts
|
|
622
|
-
var INDEX_NAME = "idx_sys_metadata_overlay_active";
|
|
623
|
-
var TABLE = "sys_metadata";
|
|
624
|
-
var COLUMNS = "(type, name, organization_id, environment_id, scope)";
|
|
625
|
-
var WHERE = "state = 'active'";
|
|
626
|
-
async function addSysMetadataOverlayIndex(driver) {
|
|
627
|
-
const driverAny = driver;
|
|
628
|
-
const exec = async (sql) => {
|
|
629
|
-
if (typeof driverAny.raw === "function") {
|
|
630
|
-
await driverAny.raw(sql);
|
|
631
|
-
} else if (typeof driverAny.execute === "function") {
|
|
632
|
-
await driverAny.execute(sql);
|
|
633
|
-
} else {
|
|
634
|
-
throw new Error("driver has neither raw nor execute");
|
|
635
|
-
}
|
|
636
|
-
};
|
|
637
|
-
const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;
|
|
638
|
-
const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;
|
|
639
|
-
try {
|
|
640
|
-
await exec(partialSql);
|
|
641
|
-
return { index: INDEX_NAME, status: "created" };
|
|
642
|
-
} catch (err) {
|
|
643
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
644
|
-
if (/partial|where clause|syntax/i.test(msg)) {
|
|
645
|
-
try {
|
|
646
|
-
await exec(fallbackSql);
|
|
647
|
-
return { index: INDEX_NAME, status: "fallback_non_unique" };
|
|
648
|
-
} catch (fallbackErr) {
|
|
649
|
-
return {
|
|
650
|
-
index: INDEX_NAME,
|
|
651
|
-
status: "error",
|
|
652
|
-
error: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
if (/already exists/i.test(msg)) {
|
|
657
|
-
return { index: INDEX_NAME, status: "already_exists" };
|
|
658
|
-
}
|
|
659
|
-
return { index: INDEX_NAME, status: "error", error: msg };
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
675
|
// src/migrations/migrate-project-id-to-environment-id.ts
|
|
664
676
|
var AFFECTED_TABLES = [
|
|
665
677
|
"sys_metadata",
|
|
@@ -809,23 +821,40 @@ var DatabaseLoader = class {
|
|
|
809
821
|
// ==========================================
|
|
810
822
|
// Internal CRUD helpers (driver vs engine)
|
|
811
823
|
// ==========================================
|
|
824
|
+
// NOTE (#6231, closed out by #7178): BOTH branches below now take `query`
|
|
825
|
+
// unchanged and uncast. `DriverQuery` is `Omit<QueryAST, 'object'>`, so the
|
|
826
|
+
// object name travels as argument one only — that was always enough for the
|
|
827
|
+
// driver branch. The ENGINE branch used to carry `as any`, for one reason:
|
|
828
|
+
// `EngineQueryOptionsSchema.search` admitted only the structured
|
|
829
|
+
// `FullTextSearchSchema`, while `QueryAST.search` (hence `DriverQuery`) also
|
|
830
|
+
// admits the bare query string that ADR-0061 D1 calls the canonical Tier-1
|
|
831
|
+
// spelling and that the engine actually serves, so `DriverQuery` was not
|
|
832
|
+
// assignable to `EngineQueryOptionsParsed`. #7178 aligned the two schemas;
|
|
833
|
+
// the casts are now genuinely vestigial and are gone, which restores real
|
|
834
|
+
// `where`/`orderBy`/`fields` checking on the metadata main read path — this
|
|
835
|
+
// schema is not `.strict()`, so an unknown key here is SILENTLY DROPPED
|
|
836
|
+
// (`check:query-options-erasure`'s own rationale) and the erased type was
|
|
837
|
+
// the only thing standing between a typo and that silence.
|
|
838
|
+
//
|
|
839
|
+
// If a future edit makes one of these stop compiling, the honest fix is to
|
|
840
|
+
// reconcile the two schemas again — not to reinstate the cast.
|
|
812
841
|
async _find(table, query) {
|
|
813
842
|
if (this.engine) {
|
|
814
843
|
return this.engine.find(table, query);
|
|
815
844
|
}
|
|
816
|
-
return this.driver.find(table,
|
|
845
|
+
return this.driver.find(table, query);
|
|
817
846
|
}
|
|
818
847
|
async _findOne(table, query) {
|
|
819
848
|
if (this.engine) {
|
|
820
849
|
return this.engine.findOne(table, query);
|
|
821
850
|
}
|
|
822
|
-
return this.driver.findOne(table,
|
|
851
|
+
return this.driver.findOne(table, query);
|
|
823
852
|
}
|
|
824
853
|
async _count(table, query) {
|
|
825
854
|
if (this.engine) {
|
|
826
855
|
return this.engine.count(table, query);
|
|
827
856
|
}
|
|
828
|
-
return this.driver.count(table,
|
|
857
|
+
return this.driver.count(table, query);
|
|
829
858
|
}
|
|
830
859
|
async _create(table, data) {
|
|
831
860
|
if (this.engine) {
|
|
@@ -907,9 +936,12 @@ var DatabaseLoader = class {
|
|
|
907
936
|
}
|
|
908
937
|
if (driver) {
|
|
909
938
|
await migrateProjectIdToEnvironmentId(driver).catch(() => void 0);
|
|
910
|
-
await addSysMetadataOverlayIndex(driver);
|
|
911
939
|
}
|
|
912
|
-
} catch {
|
|
940
|
+
} catch (error) {
|
|
941
|
+
console.warn(
|
|
942
|
+
`[Metadata] Could not resolve a raw-SQL driver from the engine for \`${this.tableName}\` \u2014 the project_id\u2192environment_id forward migration was SKIPPED. Legacy rows (if any) keep the pre-v5.0 column and read back as unset. Metadata reads and writes are otherwise unaffected. Re-run it explicitly with \`migrateProjectIdToEnvironmentId(driver)\` from \`@objectstack/metadata/migrations\` once the datasource is reachable.`,
|
|
943
|
+
error
|
|
944
|
+
);
|
|
913
945
|
}
|
|
914
946
|
return;
|
|
915
947
|
}
|
|
@@ -941,10 +973,6 @@ var DatabaseLoader = class {
|
|
|
941
973
|
await migrateProjectIdToEnvironmentId(this.driver);
|
|
942
974
|
} catch {
|
|
943
975
|
}
|
|
944
|
-
try {
|
|
945
|
-
await addSysMetadataOverlayIndex(this.driver);
|
|
946
|
-
} catch {
|
|
947
|
-
}
|
|
948
976
|
}
|
|
949
977
|
/**
|
|
950
978
|
* Ensure the history table exists.
|
|
@@ -1552,6 +1580,60 @@ function generateId() {
|
|
|
1552
1580
|
|
|
1553
1581
|
// src/endpoint-matcher.ts
|
|
1554
1582
|
var import_api = require("@objectstack/spec/api");
|
|
1583
|
+
|
|
1584
|
+
// src/stored-envelope.ts
|
|
1585
|
+
var STORED_ENVELOPE_KEYS = Object.freeze([
|
|
1586
|
+
"package",
|
|
1587
|
+
"packageId",
|
|
1588
|
+
"publishedAt",
|
|
1589
|
+
"publishedBy",
|
|
1590
|
+
"publishedDefinition",
|
|
1591
|
+
"state",
|
|
1592
|
+
"version"
|
|
1593
|
+
]);
|
|
1594
|
+
var STORED_BODY_KEY = "metadata";
|
|
1595
|
+
var ENVELOPE_KEYS = /* @__PURE__ */ new Set([...STORED_ENVELOPE_KEYS, STORED_BODY_KEY]);
|
|
1596
|
+
var EMPTY_ENVELOPE = Object.freeze({});
|
|
1597
|
+
function peelStoredEnvelope(item) {
|
|
1598
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
1599
|
+
return { envelope: EMPTY_ENVELOPE, body: item, wrapped: false };
|
|
1600
|
+
}
|
|
1601
|
+
const row = item;
|
|
1602
|
+
const wrappedBody = row[STORED_BODY_KEY];
|
|
1603
|
+
if (wrappedBody !== void 0 && wrappedBody !== null) {
|
|
1604
|
+
const envelope2 = {};
|
|
1605
|
+
for (const key of Object.keys(row)) {
|
|
1606
|
+
if (key === STORED_BODY_KEY) continue;
|
|
1607
|
+
envelope2[key] = row[key];
|
|
1608
|
+
}
|
|
1609
|
+
return { envelope: Object.freeze(envelope2), body: wrappedBody, wrapped: true };
|
|
1610
|
+
}
|
|
1611
|
+
let envelope;
|
|
1612
|
+
for (const key of Object.keys(row)) {
|
|
1613
|
+
if (!ENVELOPE_KEYS.has(key)) continue;
|
|
1614
|
+
envelope ?? (envelope = {});
|
|
1615
|
+
envelope[key] = row[key];
|
|
1616
|
+
}
|
|
1617
|
+
if (!envelope) return { envelope: EMPTY_ENVELOPE, body: row, wrapped: false };
|
|
1618
|
+
const body = {};
|
|
1619
|
+
for (const key of Object.keys(row)) {
|
|
1620
|
+
if (ENVELOPE_KEYS.has(key)) continue;
|
|
1621
|
+
body[key] = row[key];
|
|
1622
|
+
}
|
|
1623
|
+
return { envelope: Object.freeze(envelope), body, wrapped: false };
|
|
1624
|
+
}
|
|
1625
|
+
function storedItemName(peeled) {
|
|
1626
|
+
const fromEnvelope = peeled.envelope.name;
|
|
1627
|
+
if (typeof fromEnvelope === "string") return fromEnvelope;
|
|
1628
|
+
const body = peeled.body;
|
|
1629
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
1630
|
+
const fromBody = body.name;
|
|
1631
|
+
if (typeof fromBody === "string") return fromBody;
|
|
1632
|
+
}
|
|
1633
|
+
return void 0;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// src/endpoint-matcher.ts
|
|
1555
1637
|
function normalizeEndpointMethod(method) {
|
|
1556
1638
|
return String(method ?? "").toUpperCase();
|
|
1557
1639
|
}
|
|
@@ -1561,9 +1643,10 @@ function endpointIndexKey(method, path3) {
|
|
|
1561
1643
|
function buildEndpointIndex(items, logger) {
|
|
1562
1644
|
const index = /* @__PURE__ */ new Map();
|
|
1563
1645
|
for (const item of items) {
|
|
1564
|
-
const
|
|
1646
|
+
const peeled = peelStoredEnvelope(item);
|
|
1647
|
+
const parsed = import_api.ApiEndpointSchema.safeParse(peeled.body);
|
|
1565
1648
|
if (!parsed.success) {
|
|
1566
|
-
const declaredName =
|
|
1649
|
+
const declaredName = storedItemName(peeled) ?? "<unnamed>";
|
|
1567
1650
|
logger.error(
|
|
1568
1651
|
`[EndpointMatcher] stored api item '${declaredName}' does not satisfy ApiEndpointSchema \u2014 it is EXCLUDED from endpoint matching and its declared route will answer 404. Fix the declaration (or remove it); the endpoint index never serves a half-valid shape.`,
|
|
1569
1652
|
void 0,
|
|
@@ -1702,16 +1785,40 @@ var _MetadataManager = class _MetadataManager {
|
|
|
1702
1785
|
// above. The concurrent half is delivered by `inflightListReads` below, which
|
|
1703
1786
|
// is why the two fields are one policy and are documented together.
|
|
1704
1787
|
//
|
|
1705
|
-
// [#5184] That hazard is NOT
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1788
|
+
// [#5184; re-measured under #7708 on 2026-08-11] That hazard is NOT
|
|
1789
|
+
// historical — and the re-measurement NARROWED it rather than retiring it.
|
|
1790
|
+
// Measured on the current stack: real `ObjectQL` + real `SqlDriver`
|
|
1791
|
+
// (better-sqlite3), a real `DatabaseLoader.list()` with the loader's own
|
|
1792
|
+
// cache off, `knex.client.pool.max === 1` confirmed for the SQLite dialect
|
|
1793
|
+
// and `acquireConnectionTimeout` left at the knex default of 60s.
|
|
1794
|
+
//
|
|
1795
|
+
// • Transaction opened DIRECTLY on the driver (`driver.beginTransaction()`)
|
|
1796
|
+
// → the read STALLS for the full timeout and then throws knex's "Timeout
|
|
1797
|
+
// acquiring a connection" (measured: 60_085ms). `DatabaseLoader._find()`
|
|
1798
|
+
// forwards no options, so nothing threads the caller's transaction, and
|
|
1799
|
+
// `driver-sql` still models SQLite as a single-connection pool
|
|
1800
|
+
// (`activeTransactions`, `assertBareKnexSafe` — the latter a dev/test
|
|
1801
|
+
// guard that is a no-op in production, so production still waits the
|
|
1802
|
+
// timeout out). `list()` catches that throw and degrades, which is
|
|
1803
|
+
// precisely the entry whose TTL this policy is choosing.
|
|
1804
|
+
// • Transaction opened through `engine.transaction()` / `ScopedContext`
|
|
1805
|
+
// → returns immediately (measured: 12ms, with `activeTransactions === 1`
|
|
1806
|
+
// and the driver observably receiving the handle on the call). Those
|
|
1807
|
+
// publish the transaction into the engine's ambient `txStore` (ADR-0034)
|
|
1808
|
+
// and `buildDriverOptions` threads it onto the read for the loader.
|
|
1809
|
+
//
|
|
1810
|
+
// So the stall shape is live but CONDITIONAL: it needs an open transaction
|
|
1811
|
+
// that the engine's ambient store cannot see. `SqlDriver.ensureSequencesTable()`
|
|
1812
|
+
// is the live witness that this is worth designing against — it takes
|
|
1813
|
+
// `parentTrx` and runs its DDL on the caller's transaction for exactly this
|
|
1814
|
+
// reason, with `assertBareKnexSafe` as the tripwire for callers that forget;
|
|
1815
|
+
// `sql-driver-sqlite-tx-guard.test.ts` pins both halves. (Until #7708 the
|
|
1816
|
+
// witness cited here was `plugin-audit`'s `captureBefore`, retired by #6656.
|
|
1817
|
+
// It was REPLACED rather than dropped: the example died, the hazard did not.)
|
|
1818
|
+
//
|
|
1819
|
+
// Hence the policy below keeps caching degraded reads rather than skipping
|
|
1820
|
+
// them: "don't cache a degraded read" would trade one 30s silent window for
|
|
1821
|
+
// a fresh 60s stall per call on every caller in the first bullet.
|
|
1715
1822
|
//
|
|
1716
1823
|
// [#5184] WHAT IS ACTUALLY CACHED, AND FOR HOW LONG — this paragraph is the
|
|
1717
1824
|
// contract, and it describes `cacheListResult()` / `readCachedList()` below.
|
|
@@ -1798,6 +1905,15 @@ var _MetadataManager = class _MetadataManager {
|
|
|
1798
1905
|
* only, so a fresh read that already replaced it keeps its slot. Nothing
|
|
1799
1906
|
* accumulates — a wave of callers arriving after settle finds the cache the
|
|
1800
1907
|
* settle just wrote, and once that lapses it starts one new read.
|
|
1908
|
+
*
|
|
1909
|
+
* [#6504] The shared value is the whole {@link ListReadResult}, not just
|
|
1910
|
+
* `items`. "Sharers share the outcome" above is stated about the answer *and*
|
|
1911
|
+
* its degraded verdict, and while the promise carried only `items` that was
|
|
1912
|
+
* true of `list()` alone: a {@link listDiagnosed} caller joining an in-flight
|
|
1913
|
+
* read had no way to reach the verdict that read had already computed, and
|
|
1914
|
+
* would have had to either re-walk the loaders (defeating this map) or invent
|
|
1915
|
+
* a second, unmemoized answer. `list()` narrows to `.items` at its own return
|
|
1916
|
+
* instead, so every sharer still receives the same array instance.
|
|
1801
1917
|
*/
|
|
1802
1918
|
this.inflightListReads = /* @__PURE__ */ new Map();
|
|
1803
1919
|
// [#5108] Loader names whose read failure has already been reported at
|
|
@@ -1994,6 +2110,8 @@ var _MetadataManager = class _MetadataManager {
|
|
|
1994
2110
|
* {@link MetadataWriteOptions.notify} before doing so.
|
|
1995
2111
|
*/
|
|
1996
2112
|
async register(type, name, data, options) {
|
|
2113
|
+
(0, import_core.assertMetadataRegisterContract)(type, name, data);
|
|
2114
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
1997
2115
|
if (this.config.persistence?.writable === false) {
|
|
1998
2116
|
const msg = `MetadataManager is read-only (persistence.writable=false); refusing to register ${type}/${name}`;
|
|
1999
2117
|
if (this.config.validation?.throwOnError) {
|
|
@@ -2046,6 +2164,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2046
2164
|
* consumers will read the pre-write definition until restart.
|
|
2047
2165
|
*/
|
|
2048
2166
|
registerInMemory(type, name, data) {
|
|
2167
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
2049
2168
|
if (!this.registry.has(type)) {
|
|
2050
2169
|
this.registry.set(type, /* @__PURE__ */ new Map());
|
|
2051
2170
|
}
|
|
@@ -2062,18 +2181,33 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2062
2181
|
* has with {@link loadDiagnosed}, so every existing caller keeps its exact
|
|
2063
2182
|
* behaviour and only callers that ASK for the verdict pay for it.
|
|
2064
2183
|
*
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2071
|
-
*
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
2074
|
-
*
|
|
2184
|
+
* Not expressed as `(await getDiagnosed(…)).data`, although that is what it
|
|
2185
|
+
* computes — and the reason has CHANGED, so do not read the duplication as a
|
|
2186
|
+
* standing constraint.
|
|
2187
|
+
*
|
|
2188
|
+
* [#5840] recorded the delegation as unsafe: it adds one `await` hop, and
|
|
2189
|
+
* `register-notifies-watchers.test.ts` went red on the delegating version, so
|
|
2190
|
+
* three lines were duplicated to hold the frame count fixed. [#6043] measured
|
|
2191
|
+
* that test and found it was pinning this method's microtask depth rather than
|
|
2192
|
+
* the ordering guarantee it named — `notifyWatchers` never awaits its handlers,
|
|
2193
|
+
* so a subscriber's `await get(…)` had simply been settling inside the
|
|
2194
|
+
* microtasks `await register(…)` yields. That case now asserts the ordering
|
|
2195
|
+
* synchronously against the registry and does not observe this method's frame
|
|
2196
|
+
* count at all; the whole `@objectstack/metadata` suite was re-measured on the
|
|
2197
|
+
* delegating version and stayed green.
|
|
2198
|
+
*
|
|
2199
|
+
* What survives is a plain, local reason: the registry hit is the hot path and
|
|
2200
|
+
* answering it without a second async frame is worth three lines. Nothing
|
|
2201
|
+
* external depends on the hop count any more. Consolidating the two into one
|
|
2202
|
+
* delegation is therefore a viable, deliberately un-taken change (#6043 was
|
|
2203
|
+
* test-scoped) — if you take it, note that `get()`'s callers outside this
|
|
2204
|
+
* package were never surveyed for timing sensitivity, only this package's
|
|
2205
|
+
* tests. Either way the two stay pinned to each other from the other side:
|
|
2206
|
+
* `get()` and `getDiagnosed().data` are asserted to agree on every case in
|
|
2207
|
+
* `metadata-manager-get-diagnosed.test.ts`.
|
|
2075
2208
|
*/
|
|
2076
2209
|
async get(type, name) {
|
|
2210
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
2077
2211
|
const typeStore = this.registry.get(type);
|
|
2078
2212
|
if (typeStore?.has(name)) {
|
|
2079
2213
|
return typeStore.get(name);
|
|
@@ -2105,6 +2239,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2105
2239
|
* cannot prove the item is absent, so we decline to claim it is.
|
|
2106
2240
|
*/
|
|
2107
2241
|
async getDiagnosed(type, name) {
|
|
2242
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
2108
2243
|
const typeStore = this.registry.get(type);
|
|
2109
2244
|
if (typeStore?.has(name)) {
|
|
2110
2245
|
return { data: typeStore.get(name), degraded: false, errors: [] };
|
|
@@ -2126,19 +2261,64 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2126
2261
|
* `listCache`.
|
|
2127
2262
|
*/
|
|
2128
2263
|
async list(type) {
|
|
2264
|
+
return (await this.readList((0, import_core.canonicalMetadataServiceType)(type))).items;
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* `list`, plus whether the answer can be trusted as complete.
|
|
2268
|
+
*
|
|
2269
|
+
* [#6504] The plural counterpart of {@link getDiagnosed}, and the same defect
|
|
2270
|
+
* one read over: `readListUncached` has computed this verdict since #5184 and
|
|
2271
|
+
* `list()` spent it entirely on a cache TTL, so a consumer receiving a short
|
|
2272
|
+
* set could not ask whether it was short because that is all anyone declared
|
|
2273
|
+
* or because a loader was down. {@link reportLoaderReadFailure}'s own message
|
|
2274
|
+
* says what that costs — "every list served from now on is a PARTIAL set
|
|
2275
|
+
* presented as a complete one, and the server keeps reporting healthy" — and
|
|
2276
|
+
* until this member existed that sentence was addressed to a log reader only,
|
|
2277
|
+
* because no caller had a way to ask.
|
|
2278
|
+
*
|
|
2279
|
+
* Sharper than the singular case rather than merely analogous: `list` is the
|
|
2280
|
+
* read whose answer carries a **count**, and a consumer restating
|
|
2281
|
+
* `items.length` as "this environment contains N items" makes a positive,
|
|
2282
|
+
* numeric claim about what an author declared out of a read that partly did
|
|
2283
|
+
* not happen.
|
|
2284
|
+
*
|
|
2285
|
+
* Reads through exactly the same cache and single-flight machinery `list()`
|
|
2286
|
+
* does — same entry, same TTLs, same in-flight join — so asking for the
|
|
2287
|
+
* verdict costs no extra loader walk, and `list()` and
|
|
2288
|
+
* `listDiagnosed().items` cannot drift: they are the same read, narrowed at
|
|
2289
|
+
* different points. `degraded` is true when at least one loader threw while
|
|
2290
|
+
* this set was assembled; unlike {@link getDiagnosed} it does NOT additionally
|
|
2291
|
+
* require that nothing answered, because a plural read that lost one loader is
|
|
2292
|
+
* partial even when the others answered plenty — which is the whole fact.
|
|
2293
|
+
*/
|
|
2294
|
+
async listDiagnosed(type) {
|
|
2295
|
+
const { items, degraded, errors } = await this.readList((0, import_core.canonicalMetadataServiceType)(type));
|
|
2296
|
+
return { items, degraded, errors };
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* The cached / single-flight read behind {@link list} and
|
|
2300
|
+
* {@link listDiagnosed}.
|
|
2301
|
+
*
|
|
2302
|
+
* [#6504] Extracted so the two members are one read seen at two widths rather
|
|
2303
|
+
* than two implementations that have to be kept in agreement — the shape
|
|
2304
|
+
* `get`/`getDiagnosed` pay for with a duplicated body and a test pinning them
|
|
2305
|
+
* to each other. Everything below is unchanged in behaviour from when it was
|
|
2306
|
+
* inlined in `list()`; only the verdict now survives the return.
|
|
2307
|
+
*/
|
|
2308
|
+
async readList(type) {
|
|
2129
2309
|
const cached = this.readCachedList(type);
|
|
2130
2310
|
if (cached) {
|
|
2131
|
-
return cached
|
|
2311
|
+
return cached;
|
|
2132
2312
|
}
|
|
2133
2313
|
const joined = this.inflightListReads.get(type);
|
|
2134
2314
|
if (joined) {
|
|
2135
2315
|
return joined;
|
|
2136
2316
|
}
|
|
2137
|
-
const shared = this.readListUncached(type).then((
|
|
2317
|
+
const shared = this.readListUncached(type).then((result) => {
|
|
2138
2318
|
if (this.inflightListReads.get(type) === shared) {
|
|
2139
|
-
this.cacheListResult(type,
|
|
2319
|
+
this.cacheListResult(type, result);
|
|
2140
2320
|
}
|
|
2141
|
-
return
|
|
2321
|
+
return result;
|
|
2142
2322
|
});
|
|
2143
2323
|
this.inflightListReads.set(type, shared);
|
|
2144
2324
|
try {
|
|
@@ -2169,6 +2349,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2169
2349
|
}
|
|
2170
2350
|
}
|
|
2171
2351
|
let degraded = false;
|
|
2352
|
+
const errors = [];
|
|
2172
2353
|
for (const loader of this.loaders.values()) {
|
|
2173
2354
|
try {
|
|
2174
2355
|
const loaderItems = await loader.loadMany(type);
|
|
@@ -2181,10 +2362,11 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2181
2362
|
this.reportLoaderReadRecovered(loader.contract.name);
|
|
2182
2363
|
} catch (e) {
|
|
2183
2364
|
degraded = true;
|
|
2365
|
+
errors.push(`${loader.contract.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
2184
2366
|
this.reportLoaderReadFailure(loader.contract.name, type, e);
|
|
2185
2367
|
}
|
|
2186
2368
|
}
|
|
2187
|
-
return { items: Array.from(items.values()), degraded };
|
|
2369
|
+
return { items: Array.from(items.values()), degraded, errors };
|
|
2188
2370
|
}
|
|
2189
2371
|
/**
|
|
2190
2372
|
* Report — at `error`, once per outage episode — that a loader could not be
|
|
@@ -2246,9 +2428,14 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2246
2428
|
* one thing this cache used to throw away. A result assembled while a loader
|
|
2247
2429
|
* was unreadable is stored, but stored *as* what it is, so it expires on the
|
|
2248
2430
|
* degraded TTL and any reader can tell it apart from a complete answer.
|
|
2431
|
+
*
|
|
2432
|
+
* [#6504] Takes the whole read result rather than its parts for the same
|
|
2433
|
+
* reason: a signature that spreads the verdict across positional arguments is
|
|
2434
|
+
* one a later caller can quietly fill with `false`, which is how the verdict
|
|
2435
|
+
* was lost on the way out in the first place.
|
|
2249
2436
|
*/
|
|
2250
|
-
cacheListResult(type,
|
|
2251
|
-
this.listCache.set(type, { ts: Date.now(),
|
|
2437
|
+
cacheListResult(type, result) {
|
|
2438
|
+
this.listCache.set(type, { ts: Date.now(), ...result });
|
|
2252
2439
|
}
|
|
2253
2440
|
/**
|
|
2254
2441
|
* Read a still-fresh {@link listCache} entry, or `undefined` when there is
|
|
@@ -2401,6 +2588,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2401
2588
|
* before the await would buy nothing and would re-open step 1's window.
|
|
2402
2589
|
*/
|
|
2403
2590
|
async unregister(type, name, options) {
|
|
2591
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
2404
2592
|
for (const loader of this.loaders.values()) {
|
|
2405
2593
|
if (loader.contract.protocol !== "datasource:" || !loader.contract.capabilities.write) continue;
|
|
2406
2594
|
if (typeof loader.delete !== "function") continue;
|
|
@@ -2503,6 +2691,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2503
2691
|
* Check if a metadata item exists
|
|
2504
2692
|
*/
|
|
2505
2693
|
async exists(type, name) {
|
|
2694
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
2506
2695
|
if (this.registry.get(type)?.has(name)) {
|
|
2507
2696
|
return true;
|
|
2508
2697
|
}
|
|
@@ -2517,6 +2706,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2517
2706
|
* List all names of metadata items of a given type
|
|
2518
2707
|
*/
|
|
2519
2708
|
async listNames(type) {
|
|
2709
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
2520
2710
|
const names = /* @__PURE__ */ new Set();
|
|
2521
2711
|
const typeStore = this.registry.get(type);
|
|
2522
2712
|
if (typeStore) {
|
|
@@ -2747,10 +2937,16 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2747
2937
|
* ## What it judges, and on what
|
|
2748
2938
|
*
|
|
2749
2939
|
* The registry stores either a raw spec document or a publish envelope
|
|
2750
|
-
* (`{ name, packageId, state, metadata: {…spec} }`)
|
|
2751
|
-
*
|
|
2752
|
-
*
|
|
2753
|
-
* the
|
|
2940
|
+
* (`{ name, packageId, state, metadata: {…spec} }`), and in BOTH shapes the
|
|
2941
|
+
* row carries the metadata layer's bookkeeping. [#5309] The envelope is
|
|
2942
|
+
* peeled off first (`peelStoredEnvelope`) and the gate judges the authored
|
|
2943
|
+
* BODY: the wrapped half of that peel is the `data.metadata ?? data` rule
|
|
2944
|
+
* this method used to spell inline — the same document `publishedDefinition`
|
|
2945
|
+
* snapshots — and the flat half additionally removes `packageId` / `state` /
|
|
2946
|
+
* `version` / `published*`, which are storage identity, never endpoint
|
|
2947
|
+
* vocabulary. (What publish SNAPSHOTS is unchanged: `publishedDefinition`
|
|
2948
|
+
* still stores `data.metadata ?? data` verbatim, envelope included, because
|
|
2949
|
+
* `revertPackage` restores from it.) An item whose body does not satisfy
|
|
2754
2950
|
* `ApiEndpointSchema` fails here too — not extra strictness but a
|
|
2755
2951
|
* precondition: an unparsed shape cannot be gated, and it could never be
|
|
2756
2952
|
* served either (the matcher's own loud skip refuses it at load).
|
|
@@ -2768,8 +2964,8 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2768
2964
|
const endpoints = [];
|
|
2769
2965
|
const gatedItems = [];
|
|
2770
2966
|
for (const item of apiItems) {
|
|
2771
|
-
const
|
|
2772
|
-
const parsed = import_api3.ApiEndpointSchema.safeParse(
|
|
2967
|
+
const { body } = peelStoredEnvelope(item.data);
|
|
2968
|
+
const parsed = import_api3.ApiEndpointSchema.safeParse(body);
|
|
2773
2969
|
if (!parsed.success) {
|
|
2774
2970
|
for (const issue of parsed.error.issues) {
|
|
2775
2971
|
errors.push({
|
|
@@ -2816,11 +3012,21 @@ var _MetadataManager = class _MetadataManager {
|
|
|
2816
3012
|
}
|
|
2817
3013
|
}
|
|
2818
3014
|
if (packageItems.length === 0) {
|
|
2819
|
-
|
|
3015
|
+
const err = new Error(
|
|
3016
|
+
`No metadata items found for package '${packageId}'`
|
|
3017
|
+
);
|
|
3018
|
+
err.code = "RESOURCE_NOT_FOUND";
|
|
3019
|
+
err.status = 404;
|
|
3020
|
+
throw err;
|
|
2820
3021
|
}
|
|
2821
3022
|
const hasPublished = packageItems.some((item) => item.data.publishedDefinition !== void 0);
|
|
2822
3023
|
if (!hasPublished) {
|
|
2823
|
-
|
|
3024
|
+
const err = new Error(
|
|
3025
|
+
`Package '${packageId}' has never been published`
|
|
3026
|
+
);
|
|
3027
|
+
err.code = "RESOURCE_CONFLICT";
|
|
3028
|
+
err.status = 409;
|
|
3029
|
+
throw err;
|
|
2824
3030
|
}
|
|
2825
3031
|
for (const item of packageItems) {
|
|
2826
3032
|
if (item.data.publishedDefinition !== void 0) {
|
|
@@ -3073,6 +3279,7 @@ var _MetadataManager = class _MetadataManager {
|
|
|
3073
3279
|
* @returns An unsubscribe function.
|
|
3074
3280
|
*/
|
|
3075
3281
|
subscribe(type, callback) {
|
|
3282
|
+
type = (0, import_core.canonicalMetadataServiceType)(type);
|
|
3076
3283
|
this.addWatchCallback(type, callback);
|
|
3077
3284
|
return () => this.removeWatchCallback(type, callback);
|
|
3078
3285
|
}
|
|
@@ -4353,8 +4560,16 @@ var ARTIFACT_FIELD_TO_TYPE = {
|
|
|
4353
4560
|
connectors: "connector",
|
|
4354
4561
|
emailTemplates: "email_template",
|
|
4355
4562
|
docs: "doc",
|
|
4356
|
-
books: "book"
|
|
4357
|
-
data
|
|
4563
|
+
books: "book"
|
|
4564
|
+
// `data:` (the SEED collection) is deliberately absent — #6242 row 4(a).
|
|
4565
|
+
// It used to map to `'dataset'`, the ADR-0021 analytics kind: the exact name
|
|
4566
|
+
// collision `metadata-plugin.zod.ts` warns about in prose. The entry never
|
|
4567
|
+
// registered anything (SeedSchema declares no `name`, and the loop below
|
|
4568
|
+
// skips nameless items) — a dead pointer aimed at the wrong kind, which
|
|
4569
|
+
// would have begun mis-registering the day either side moved. Removed rather
|
|
4570
|
+
// than repointed at `'seed'`: seeds are APPLIED by SeedLoaderService off the
|
|
4571
|
+
// bundle, never registered as metadata items, so a `seed` mapping would be
|
|
4572
|
+
// new behaviour rather than a corrected name.
|
|
4358
4573
|
};
|
|
4359
4574
|
var MetadataPlugin = class {
|
|
4360
4575
|
constructor(options = {}) {
|