bazis 0.98.22 → 0.98.24
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/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/startupFailure.ts +3 -0
- package/core/orm/ormModule.ts +8 -2
- package/core/orm/ownedStoreContributions.ts +27 -15
- package/library/orm/DbContext.ts +1 -1
- package/library/orm/Metadata/DynamicModelBuilder.ts +3 -4
- package/library/orm/Metadata/OrmModel.ts +3 -0
- package/library/orm/Schema/OrmOwnedStore.ts +22 -18
- package/library/orm/Schema/OwnedStoreAdmission.ts +19 -0
- package/library/orm/Schema/OwnedStoreExplain.ts +103 -0
- package/library/orm/errors.ts +7 -2
- package/package.json +1 -1
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.24" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -37,6 +37,9 @@ export function formatStartupFailure(error: unknown): string {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function codeOf(error: Error): string {
|
|
40
|
+
const own = (error as unknown as Record<string, unknown>).code;
|
|
41
|
+
// A message that already starts with its code ("ORM_X: …") is not prefixed twice.
|
|
42
|
+
if (typeof own === "string" && error.message.startsWith(own)) return "";
|
|
40
43
|
// Node style: `Error [ERR_X]: message`; a database error code such as 42P01 follows it.
|
|
41
44
|
const fields = ["code", "errno"].flatMap((name) => {
|
|
42
45
|
const value = (error as unknown as Record<string, unknown>)[name];
|
package/core/orm/ormModule.ts
CHANGED
|
@@ -110,8 +110,14 @@ function defineConnectionModule(provider: DatabaseProvider, healthCheck: boolean
|
|
|
110
110
|
/** Single ORM registration entry point (see {@link OrmModuleConfig}). */
|
|
111
111
|
export function ormModule<TContext extends DbContext>(config: OrmModuleConfig<TContext>): BazisModuleRef {
|
|
112
112
|
if (config.ownedStore !== undefined) {
|
|
113
|
-
if (!config.context || !config.entities || config.entities.length === 0
|
|
114
|
-
throw new OrmError("ORM owned store requires a context and entities
|
|
113
|
+
if (!config.context || !config.entities || config.entities.length === 0) {
|
|
114
|
+
throw new OrmError("ORM owned store requires a context and entities.");
|
|
115
|
+
}
|
|
116
|
+
if (config.provider) {
|
|
117
|
+
throw new OrmError("ORM owned store uses the shared DATABASE_PROVIDER from @Infra; remove provider from this ormBazis entry.");
|
|
118
|
+
}
|
|
119
|
+
if (config.ensureCreated || config.migrateOnStart || config.runMigrationsOnStart || config.migrations !== undefined) {
|
|
120
|
+
throw new OrmError("ORM owned store creates and verifies its own tables; remove ensureCreated, migrateOnStart and migrations from this ormBazis entry.");
|
|
115
121
|
}
|
|
116
122
|
// Provider factories outlive this call. Keep their context/entity view
|
|
117
123
|
// registration-local so a caller cannot mutate the descriptor afterwards
|
|
@@ -41,9 +41,13 @@ const PAYLOAD = Symbol("bazis.orm.owned-store");
|
|
|
41
41
|
const CHANNEL = createModuleOwnedMetadataChannel<OrmGraphContribution>("orm.owned-store");
|
|
42
42
|
const compiled = new WeakMap<object, CompiledOwnedStoreGraph>();
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/** Names here come from the application's own declarations, never from the database. */
|
|
45
|
+
function fail(code: "ORM_OWNED_STORE_IDENTITY_MISMATCH" | "ORM_OWNED_STORE_OWNERSHIP_CONFLICT", reason?: string): never {
|
|
46
|
+
throw new OrmOwnedStoreAdmissionError(code, reason === undefined ? code : `${code}: ${reason}.`);
|
|
46
47
|
}
|
|
48
|
+
const storeName = (definition: Readonly<OrmOwnedStoreDefinitionV1>) => `Owned store "${definition.storeKey}" (schema "${definition.ownedScope.schema}", prefix "${definition.ownedScope.tablePrefix}")`;
|
|
49
|
+
const scopeName = (scope: { readonly schema: string; readonly tablePrefix: string }) => `schema "${scope.schema}", prefix "${scope.tablePrefix}"`;
|
|
50
|
+
const OVERLAP_HINT = "two prefixes overlap when one starts with the other, so \"notes_\" and \"notes_v2_\" overlap while \"notes_\" and \"notesv2_\" do not";
|
|
47
51
|
|
|
48
52
|
function overlaps(a: { readonly schema: string; readonly tablePrefix: string }, b: { readonly schema: string; readonly tablePrefix: string }): boolean {
|
|
49
53
|
return a.schema === b.schema && (a.tablePrefix.startsWith(b.tablePrefix) || b.tablePrefix.startsWith(a.tablePrefix));
|
|
@@ -52,7 +56,7 @@ function overlaps(a: { readonly schema: string; readonly tablePrefix: string },
|
|
|
52
56
|
function snapshot<TContext extends DbContext>(context: ContextClass<TContext>, entities: readonly EntityClass[], source: unknown): OwnedStoreRegistration<TContext> {
|
|
53
57
|
const definition = isDefinedOrmOwnedStoreV1(source) ? source : defineOrmOwnedStoreV1(source as OrmOwnedStoreDefinitionV1);
|
|
54
58
|
const expected = compileExpectedSchema(new OrmModel(entities));
|
|
55
|
-
if (expected.tables.length === 0 || expected.tables.length > 512) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH");
|
|
59
|
+
if (expected.tables.length === 0 || expected.tables.length > 512) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)} needs 1 to 512 entities, got ${expected.tables.length}`);
|
|
56
60
|
return Object.freeze({ identity: Object.freeze({}), context, entities: Object.freeze([...entities]), definition, expected, source });
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -91,11 +95,11 @@ export function revalidateOwnedStoreRegistration(registration: OwnedStoreRegistr
|
|
|
91
95
|
&& current.definition.ownedScope.schema === registration.definition.ownedScope.schema
|
|
92
96
|
&& current.definition.ownedScope.tablePrefix === registration.definition.ownedScope.tablePrefix
|
|
93
97
|
&& JSON.stringify(current.definition.rejectIfPresent ?? []) === JSON.stringify(registration.definition.rejectIfPresent ?? []);
|
|
94
|
-
if (!sameDefinition || current.expected.tables.length !== registration.expected.tables.length) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH");
|
|
98
|
+
if (!sameDefinition || current.expected.tables.length !== registration.expected.tables.length) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(registration.definition)}: its definition or entities changed after the module was declared; declare them once`);
|
|
95
99
|
for (let index = 0; index < current.expected.tables.length; index += 1) {
|
|
96
100
|
const before = registration.expected.tables[index];
|
|
97
101
|
const after = current.expected.tables[index];
|
|
98
|
-
if (before?.schema !== after?.schema || before?.table !== after?.table) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH");
|
|
102
|
+
if (before?.schema !== after?.schema || before?.table !== after?.table) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(registration.definition)}: its entities changed after the module was declared; declare them once`);
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -106,30 +110,37 @@ export function ownedStoreRegistrations(snapshot: ModuleOwnedContributionSnapsho
|
|
|
106
110
|
const graph = contributions.map((entry) => entry.payload);
|
|
107
111
|
const records = graph.flatMap((entry) => entry.owned === undefined ? [] : [entry.owned]);
|
|
108
112
|
if (records.length === 0) return Object.freeze([]);
|
|
109
|
-
if (records.length > 128) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
|
|
113
|
+
if (records.length > 128) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `An application may declare at most 128 owned stores, got ${records.length}`);
|
|
110
114
|
const keys = new Set<string>();
|
|
111
115
|
const tables = new Set<string>();
|
|
112
116
|
for (const record of records) {
|
|
113
117
|
revalidateOwnedStoreRegistration(record);
|
|
114
118
|
if (record.definition.ownedScope.schema === "public"
|
|
115
119
|
&& ["__bazis_orm_owned_stores_v1", "__bazis_orm_owned_stores_v1_pkey"].some((name) => name.startsWith(record.definition.ownedScope.tablePrefix))) {
|
|
116
|
-
fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
|
|
120
|
+
fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${storeName(record.definition)} would own the bazis registry table __bazis_orm_owned_stores_v1; choose another tablePrefix`);
|
|
117
121
|
}
|
|
118
122
|
if ((record.definition.rejectIfPresent ?? []).some((scope) => scope.schema === "public"
|
|
119
123
|
&& ["__bazis_orm_owned_stores_v1", "__bazis_orm_owned_stores_v1_pkey"].some((name) => name.startsWith(scope.tablePrefix)))) {
|
|
120
|
-
fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
|
|
124
|
+
fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${storeName(record.definition)}: rejectIfPresent would cover the bazis registry table __bazis_orm_owned_stores_v1`);
|
|
121
125
|
}
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
const rejectedOwn = (record.definition.rejectIfPresent ?? []).find((scope) => overlaps(record.definition.ownedScope, scope));
|
|
127
|
+
if (rejectedOwn) {
|
|
128
|
+
fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${storeName(record.definition)}: rejectIfPresent (${scopeName(rejectedOwn)}) overlaps its own scope; ${OVERLAP_HINT}`);
|
|
124
129
|
}
|
|
125
|
-
if (keys.has(record.definition.storeKey)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
|
|
130
|
+
if (keys.has(record.definition.storeKey)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `Owned store key "${record.definition.storeKey}" is declared by more than one ormBazis entry`);
|
|
126
131
|
keys.add(record.definition.storeKey);
|
|
127
|
-
for (const other of records)
|
|
132
|
+
for (const other of records) {
|
|
133
|
+
if (other === record) continue;
|
|
134
|
+
if (overlaps(record.definition.ownedScope, other.definition.ownedScope)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${storeName(record.definition)} overlaps the scope of owned store "${other.definition.storeKey}" (${scopeName(other.definition.ownedScope)}); ${OVERLAP_HINT}`);
|
|
135
|
+
const rejected = (other.definition.rejectIfPresent ?? []).find((scope) => overlaps(record.definition.ownedScope, scope));
|
|
136
|
+
if (rejected) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${storeName(record.definition)} lies in a scope that owned store "${other.definition.storeKey}" rejects (${scopeName(rejected)})`);
|
|
137
|
+
}
|
|
128
138
|
for (const table of record.expected.tables) {
|
|
129
139
|
const key = `${table.schema}.${table.table}`;
|
|
130
|
-
if (tables.has(key)
|
|
140
|
+
if (tables.has(key)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `Table "${table.schema}"."${table.table}" is mapped by more than one owned store entity`);
|
|
141
|
+
if (table.schema !== record.definition.ownedScope.schema || !table.table.startsWith(record.definition.ownedScope.tablePrefix)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${storeName(record.definition)}: entity table "${table.schema}"."${table.table}" lies outside the store scope; name its tables with the prefix, for example "${record.definition.ownedScope.tablePrefix}${table.table}"`);
|
|
131
142
|
tables.add(key);
|
|
132
|
-
for (const foreignKey of table.foreignKeys) if (foreignKey.target.schema !== record.definition.ownedScope.schema || !foreignKey.target.table.startsWith(record.definition.ownedScope.tablePrefix)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
|
|
143
|
+
for (const foreignKey of table.foreignKeys) if (foreignKey.target.schema !== record.definition.ownedScope.schema || !foreignKey.target.table.startsWith(record.definition.ownedScope.tablePrefix)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${storeName(record.definition)}: table "${table.table}" has a foreign key to "${foreignKey.target.schema}"."${foreignKey.target.table}" outside the store; an owned store references only its own tables`);
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
// The complete ORM graph is compiled only when an owned store is present. This
|
|
@@ -156,7 +167,8 @@ export function ownedStoreRegistrations(snapshot: ModuleOwnedContributionSnapsho
|
|
|
156
167
|
for (const table of expected.tables) {
|
|
157
168
|
const owner = records.find((record) => table.schema === record.definition.ownedScope.schema && table.table.startsWith(record.definition.ownedScope.tablePrefix));
|
|
158
169
|
const rejected = records.some((record) => (record.definition.rejectIfPresent ?? []).some((scope) => table.schema === scope.schema && table.table.startsWith(scope.tablePrefix)));
|
|
159
|
-
if (
|
|
170
|
+
if (owner && owner !== entry.owned) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${entry.context.name} maps table "${table.schema}"."${table.table}", which belongs to owned store "${owner.definition.storeKey}"; only the store's own context may map it`);
|
|
171
|
+
if (rejected) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT", `${entry.context.name} maps table "${table.schema}"."${table.table}" in a scope an owned store rejects (rejectIfPresent)`);
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
174
|
const result = Object.freeze(records);
|
package/library/orm/DbContext.ts
CHANGED
|
@@ -59,7 +59,7 @@ export abstract class DbContext {
|
|
|
59
59
|
setByName(name: string): DbSet<Record<string, unknown>> {
|
|
60
60
|
const model = this.#options.model.tryByName(name);
|
|
61
61
|
if (!model) {
|
|
62
|
-
throw new EntityNotMappedError(name);
|
|
62
|
+
throw new EntityNotMappedError(name, "dynamic");
|
|
63
63
|
}
|
|
64
64
|
return new DbSet<Record<string, unknown>>(model, this.#runtime);
|
|
65
65
|
}
|
|
@@ -184,13 +184,12 @@ function buildDynamicModelWithCtor(
|
|
|
184
184
|
const keys = properties.filter((property) => property.isKey);
|
|
185
185
|
if (def.primaryKey && keys.length > 0) throw new ModelBuildError(`Dynamic table "${def.name}" cannot combine primaryKey with legacy isKey.`);
|
|
186
186
|
if (keys.length === 0 && !def.primaryKey) {
|
|
187
|
-
throw new ModelBuildError(`Dynamic table "${def.name}" has no primary key. Mark one field with isKey.`);
|
|
187
|
+
throw new ModelBuildError(`Dynamic table "${def.name}" has no primary key. Mark one field with isKey, or set primaryKey: { properties: [...] }.`);
|
|
188
188
|
}
|
|
189
189
|
if (keys.length > 1) {
|
|
190
|
+
const names = keys.map((key) => key.propertyName);
|
|
190
191
|
throw new ModelBuildError(
|
|
191
|
-
`Dynamic table "${def.name}" declares
|
|
192
|
-
.map((key) => key.propertyName)
|
|
193
|
-
.join('", "')}"). Composite keys are not supported.`,
|
|
192
|
+
`Dynamic table "${def.name}" declares several isKey fields ("${names.join('", "')}"). Mark one field with isKey, or for a composite key use primaryKey: { properties: ["${names.join('", "')}"] }.`,
|
|
194
193
|
);
|
|
195
194
|
}
|
|
196
195
|
const key = keys[0];
|
|
@@ -70,6 +70,9 @@ export class OrmModel {
|
|
|
70
70
|
|
|
71
71
|
/** Model for an instance, by its constructor. */
|
|
72
72
|
requireForInstance(entity: object): EntityModel {
|
|
73
|
+
// Rows of dynamic tables are plain objects; their model is known only to setByName().
|
|
74
|
+
const prototype = Object.getPrototypeOf(entity);
|
|
75
|
+
if (prototype === Object.prototype || prototype === null) throw new EntityNotMappedError("Object", "plain");
|
|
73
76
|
return this.requireByCtor(entity.constructor as EntityClass);
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -28,14 +28,16 @@ const canonicalDefinitions = new WeakSet<object>();
|
|
|
28
28
|
export function defineOrmOwnedStoreV1(definition: OrmOwnedStoreDefinitionV1): Readonly<OrmOwnedStoreDefinitionV1> {
|
|
29
29
|
try {
|
|
30
30
|
rejectProxy(definition);
|
|
31
|
-
const root = closedRecord(definition, ["contract", "storeKey", "formatVersion", "ownedScope", "rejectIfPresent"], ["contract", "storeKey", "formatVersion", "ownedScope"]);
|
|
31
|
+
const root = closedRecord(definition, ["contract", "storeKey", "formatVersion", "ownedScope", "rejectIfPresent"], ["contract", "storeKey", "formatVersion", "ownedScope"], "the definition");
|
|
32
32
|
const contract = root.contract;
|
|
33
33
|
const storeKey = root.storeKey;
|
|
34
34
|
const formatVersion = root.formatVersion;
|
|
35
|
-
if (contract !== CONTRACT
|
|
36
|
-
|
|
35
|
+
if (contract !== CONTRACT) throw identity(`contract must be "${CONTRACT}"`);
|
|
36
|
+
if (!validString(storeKey, 128)) throw identity("storeKey must be a string of 1 to 128 bytes without control characters");
|
|
37
|
+
if (typeof formatVersion !== "number" || !Number.isSafeInteger(formatVersion) || formatVersion <= 0) throw identity("formatVersion must be a positive integer");
|
|
38
|
+
const ownedScope = readScope(root.ownedScope, "ownedScope");
|
|
37
39
|
const rejectIfPresent = root.rejectIfPresent === undefined ? [] : readScopes(root.rejectIfPresent);
|
|
38
|
-
if (rejectIfPresent.some((scope) => sameScope(scope, ownedScope))) throw ownership();
|
|
40
|
+
if (rejectIfPresent.some((scope) => sameScope(scope, ownedScope))) throw ownership(`rejectIfPresent of "${storeKey}" lists its own ownedScope`);
|
|
39
41
|
const canonicalRejects = uniqueSortedScopes(rejectIfPresent);
|
|
40
42
|
const result: OrmOwnedStoreDefinitionV1 = canonicalRejects.length === 0
|
|
41
43
|
? { contract: CONTRACT, storeKey, formatVersion, ownedScope }
|
|
@@ -56,40 +58,41 @@ export function isDefinedOrmOwnedStoreV1(value: unknown): value is Readonly<OrmO
|
|
|
56
58
|
|
|
57
59
|
function readScopes(value: unknown): readonly OrmCatalogScopeV1[] {
|
|
58
60
|
rejectProxy(value);
|
|
59
|
-
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) throw identity();
|
|
61
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) throw identity("rejectIfPresent must be an array of { schema, tablePrefix }");
|
|
60
62
|
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
61
63
|
if (Object.getOwnPropertySymbols(value).length !== 0) throw identity();
|
|
62
|
-
if (value.length > maxRejectScopes) throw identity();
|
|
64
|
+
if (value.length > maxRejectScopes) throw identity(`rejectIfPresent may list at most ${maxRejectScopes} scopes`);
|
|
63
65
|
const expected = new Set(["length", ...Array.from({ length: value.length }, (_, index) => String(index))]);
|
|
64
66
|
if (Object.keys(descriptors).some((key) => !expected.has(key))) throw identity();
|
|
65
67
|
const scopes: OrmCatalogScopeV1[] = [];
|
|
66
68
|
for (let index = 0; index < value.length; index++) {
|
|
67
69
|
const descriptor = descriptors[String(index)];
|
|
68
70
|
if (!descriptor || !("value" in descriptor)) throw identity();
|
|
69
|
-
scopes.push(readScope(descriptor.value));
|
|
71
|
+
scopes.push(readScope(descriptor.value, `rejectIfPresent[${index}]`));
|
|
70
72
|
}
|
|
71
73
|
return scopes;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
function readScope(value: unknown): OrmCatalogScopeV1 {
|
|
75
|
-
const record = closedRecord(value, ["schema", "tablePrefix"], ["schema", "tablePrefix"]);
|
|
76
|
-
if (!validString(record.schema, 63) || !validString(record.tablePrefix, 63)) throw identity();
|
|
76
|
+
function readScope(value: unknown, path: string): OrmCatalogScopeV1 {
|
|
77
|
+
const record = closedRecord(value, ["schema", "tablePrefix"], ["schema", "tablePrefix"], path);
|
|
78
|
+
if (!validString(record.schema, 63) || !validString(record.tablePrefix, 63)) throw identity(`${path}.schema and ${path}.tablePrefix must be strings of 1 to 63 bytes without control characters`);
|
|
77
79
|
return { schema: record.schema, tablePrefix: record.tablePrefix };
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
function closedRecord(value: unknown, allowed: readonly string[], required: readonly string[]): Record<string, unknown> {
|
|
82
|
+
function closedRecord(value: unknown, allowed: readonly string[], required: readonly string[], path: string): Record<string, unknown> {
|
|
81
83
|
rejectProxy(value);
|
|
82
|
-
if (value === null || typeof value !== "object") throw identity();
|
|
84
|
+
if (value === null || typeof value !== "object") throw identity(`${path} must be a plain object`);
|
|
83
85
|
const prototype = Object.getPrototypeOf(value);
|
|
84
|
-
if (prototype !== Object.prototype && prototype !== null) throw identity();
|
|
86
|
+
if (prototype !== Object.prototype && prototype !== null) throw identity(`${path} must be a plain object`);
|
|
85
87
|
if (Object.getOwnPropertySymbols(value).length !== 0) throw identity();
|
|
86
88
|
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
87
89
|
for (const key of Object.keys(descriptors)) {
|
|
88
|
-
|
|
90
|
+
// JSON.stringify escapes control characters of a caller-chosen key.
|
|
91
|
+
if (!allowed.includes(key)) throw identity(`${path} has an unknown field ${JSON.stringify(key.slice(0, 64))}; allowed: ${allowed.join(", ")}`);
|
|
89
92
|
const descriptor = descriptors[key]!;
|
|
90
|
-
if (!("value" in descriptor) || descriptor.value === undefined) throw identity();
|
|
93
|
+
if (!("value" in descriptor) || descriptor.value === undefined) throw identity(`${path}.${key} must be a plain value`);
|
|
91
94
|
}
|
|
92
|
-
for (const key of required) if (!Object.hasOwn(descriptors, key)) throw identity();
|
|
95
|
+
for (const key of required) if (!Object.hasOwn(descriptors, key)) throw identity(`${path} is missing the field ${key}`);
|
|
93
96
|
return Object.fromEntries(Object.entries(descriptors).map(([key, descriptor]) => [key, (descriptor as PropertyDescriptor & { value: unknown }).value]));
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -118,6 +121,7 @@ function compareScope(left: OrmCatalogScopeV1, right: OrmCatalogScopeV1): number
|
|
|
118
121
|
}
|
|
119
122
|
function sameScope(left: OrmCatalogScopeV1, right: OrmCatalogScopeV1): boolean { return left.schema === right.schema && left.tablePrefix === right.tablePrefix; }
|
|
120
123
|
function rejectProxy(value: unknown): void { if (value !== null && (typeof value === "object" || typeof value === "function") && types.isProxy(value)) throw identity(); }
|
|
121
|
-
|
|
122
|
-
function
|
|
124
|
+
/** Proxies and symbols keep the bare code: describing them could run caller traps. */
|
|
125
|
+
function identity(reason?: string): OrmOwnedStoreAdmissionError { return new OrmOwnedStoreAdmissionError(IDENTITY_MISMATCH, reason === undefined ? IDENTITY_MISMATCH : `${IDENTITY_MISMATCH}: owned store definition is invalid: ${reason}.`); }
|
|
126
|
+
function ownership(reason: string): OrmOwnedStoreAdmissionError { return new OrmOwnedStoreAdmissionError(OWNERSHIP_CONFLICT, `${OWNERSHIP_CONFLICT}: owned store definition is invalid: ${reason}.`); }
|
|
123
127
|
function deepFreeze<T>(value: T): T { if (value && typeof value === "object") { for (const child of Object.values(value)) deepFreeze(child); Object.freeze(value); } return value; }
|
|
@@ -11,6 +11,7 @@ import type { OrmCatalogScopeV1, OrmOwnedStoreDefinitionV1 } from "./OrmOwnedSto
|
|
|
11
11
|
import { isDefinedOrmOwnedStoreV1 } from "./OrmOwnedStore";
|
|
12
12
|
import { failure, postgresOwnedStoreCapability, type OwnedStoreCreateOperationV1, type OwnedStoreIdentityInsertV1, type OwnedStoreSecondaryLockEntryV1 } from "../Providers/ormOwnedStoreRuntime";
|
|
13
13
|
import { OrmOwnedStoreAdmissionError } from "../errors";
|
|
14
|
+
import { explainOwnedStoreFailureV1, type OwnedStoreObservationV1 } from "./OwnedStoreExplain";
|
|
14
15
|
import { types } from "node:util";
|
|
15
16
|
export interface PreparedOwnedStoreV1 {
|
|
16
17
|
readonly definition: Readonly<OrmOwnedStoreDefinitionV1>;
|
|
@@ -44,9 +45,25 @@ type LeaseState = {
|
|
|
44
45
|
const leaseStates = new WeakMap<object, LeaseState>();
|
|
45
46
|
const leaseCounts = new WeakMap<DatabaseProvider, WeakMap<object, number>>();
|
|
46
47
|
const preimages = new Map<string, Buffer>();
|
|
48
|
+
/**
|
|
49
|
+
* Admits the declared stores. The checks below fail closed with bare codes;
|
|
50
|
+
* on failure the code is explained from the snapshots read so far (see
|
|
51
|
+
* OwnedStoreExplain), outside every provider callback.
|
|
52
|
+
*/
|
|
47
53
|
export async function admitOwnedStoresV1(provider: DatabaseProvider, request: OwnedStoreAdmissionRequestV1): Promise<CommittedOwnedStoreAdmissionV1> {
|
|
54
|
+
const observed: OwnedStoreObservationV1 = {};
|
|
55
|
+
try {
|
|
56
|
+
return await admitOwnedStoresCoreV1(provider, request, observed);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!(error instanceof OrmOwnedStoreAdmissionError) || Object.getPrototypeOf(error) !== OrmOwnedStoreAdmissionError.prototype) throw error;
|
|
59
|
+
throw explainOwnedStoreFailureV1(error.code, observed);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function admitOwnedStoresCoreV1(provider: DatabaseProvider, request: OwnedStoreAdmissionRequestV1, observed: OwnedStoreObservationV1): Promise<CommittedOwnedStoreAdmissionV1> {
|
|
48
64
|
const admitted = prepare(request);
|
|
49
65
|
const prepared = admitted.prepared;
|
|
66
|
+
observed.stores = prepared;
|
|
50
67
|
const signal = admitted.signal;
|
|
51
68
|
if (aborted(signal))
|
|
52
69
|
throw failure("ORM_OWNED_STORE_LOCK_UNAVAILABLE");
|
|
@@ -76,6 +93,7 @@ export async function admitOwnedStoresV1(provider: DatabaseProvider, request: Ow
|
|
|
76
93
|
throw failure("ORM_OWNED_STORE_PROVIDER_UNSUPPORTED");
|
|
77
94
|
const definitions = prepared.map(item => item.definition);
|
|
78
95
|
const registry = await operational(() => session.inspectRegistry()).then(snapshot => parseOwnedStoreRegistrySnapshotV1(snapshot, definitions, context));
|
|
96
|
+
observed.registry = registry;
|
|
79
97
|
if (registry.state.kind === "present")
|
|
80
98
|
validateFixedRegistryShapeV1(registry.state.shape);
|
|
81
99
|
assertCurrent(registry, prepared);
|
|
@@ -84,6 +102,7 @@ export async function admitOwnedStoresV1(provider: DatabaseProvider, request: Ow
|
|
|
84
102
|
const plan = lockPlan(prepared, rows);
|
|
85
103
|
const locked = await session.lockSecondary(plan);
|
|
86
104
|
const catalogue = await operational(() => locked.inspectCatalog(catalogueScopes)).then(snapshot => parseOwnedStoreCatalogSnapshotV1(snapshot, context));
|
|
105
|
+
observed.catalogue = catalogue;
|
|
87
106
|
const semantic: OwnedStoreCatalogSemanticContextV1 = Object.freeze({
|
|
88
107
|
stores: Object.freeze(prepared.map(item => Object.freeze({ definition: item.definition, expectedSchema: item.expected }))), requestedScopes: catalogueScopes
|
|
89
108
|
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { OrmOwnedStoreAdmissionError, type OrmOwnedStoreAdmissionErrorCode } from "../errors";
|
|
2
|
+
import type { OrmCatalogScopeV1, OrmOwnedStoreDefinitionV1 } from "./OrmOwnedStore";
|
|
3
|
+
import type { OwnedStoreCatalogSnapshotV1, OwnedStoreRegistrySnapshotV1 } from "./OwnedStoreCatalog";
|
|
4
|
+
|
|
5
|
+
/** What admission had already read when it failed; filled only with parsed, validated snapshots. */
|
|
6
|
+
export interface OwnedStoreObservationV1 {
|
|
7
|
+
stores?: readonly { readonly definition: Readonly<OrmOwnedStoreDefinitionV1>; readonly modelHash: string; readonly ownedScopeHash: string }[];
|
|
8
|
+
registry?: OwnedStoreRegistrySnapshotV1;
|
|
9
|
+
catalogue?: OwnedStoreCatalogSnapshotV1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const REGISTRY = "__bazis_orm_owned_stores_v1";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Turns a bare admission code into a readable message. The checks themselves
|
|
16
|
+
* stay code-only; this runs after admission failed and recomputes the likely
|
|
17
|
+
* reason from the same trusted inputs. Names from the code (store keys,
|
|
18
|
+
* prefixes) are validated by `defineOrmOwnedStoreV1`; names read from the
|
|
19
|
+
* database are escaped and bounded. Row values and driver text never appear.
|
|
20
|
+
*/
|
|
21
|
+
export function explainOwnedStoreFailureV1(code: OrmOwnedStoreAdmissionErrorCode, observed: OwnedStoreObservationV1): OrmOwnedStoreAdmissionError {
|
|
22
|
+
const stores = observed.stores ?? [];
|
|
23
|
+
if (stores.length === 0) return new OrmOwnedStoreAdmissionError(code, code);
|
|
24
|
+
const rows = observed.registry?.state.kind === "present" ? observed.registry.state.rows : [];
|
|
25
|
+
const relations = (observed.catalogue?.relations ?? []).filter((relation) => relation.kind === "ordinaryTable");
|
|
26
|
+
const tablesIn = (scope: OrmCatalogScopeV1) => relations.filter((relation) => relation.schema === scope.schema && relation.name.startsWith(scope.tablePrefix)).map((relation) => relation.name);
|
|
27
|
+
const all = stores.map((store) => storeName(store.definition)).join(", ");
|
|
28
|
+
const reason = (() => {
|
|
29
|
+
switch (code) {
|
|
30
|
+
case "ORM_OWNED_STORE_IDENTITY_MISMATCH": {
|
|
31
|
+
for (const store of stores) {
|
|
32
|
+
const row = rows.find((item) => item.storeKey === store.definition.storeKey);
|
|
33
|
+
if (!row) continue;
|
|
34
|
+
if (Number(row.formatVersion) !== store.definition.formatVersion) return `${storeName(store.definition)} is registered with format version ${dbNumber(row.formatVersion)}, the code declares ${store.definition.formatVersion}. ${FROZEN}`;
|
|
35
|
+
if (row.ownedSchema !== store.definition.ownedScope.schema || row.tablePrefix !== store.definition.ownedScope.tablePrefix) return `${storeName(store.definition)} is registered with schema ${dbName(row.ownedSchema)}, prefix ${dbName(row.tablePrefix)}. ${FROZEN}`;
|
|
36
|
+
if (row.modelHash !== store.modelHash) return `${storeName(store.definition)}: its model changed since the store was created (tables, columns, keys, indexes or their names). ${FROZEN}`;
|
|
37
|
+
}
|
|
38
|
+
return `${all}: the registered identity does not match the code. ${FROZEN}`;
|
|
39
|
+
}
|
|
40
|
+
case "ORM_OWNED_STORE_IDENTITY_MISSING": {
|
|
41
|
+
for (const store of stores) {
|
|
42
|
+
if (rows.some((row) => row.storeKey === store.definition.storeKey)) continue;
|
|
43
|
+
const occupied = tablesIn(store.definition.ownedScope);
|
|
44
|
+
if (occupied.length > 0) return `${storeName(store.definition)} is not registered yet, but its scope already contains tables that no owned store registered: ${names(occupied)}. Remove them, or choose another tablePrefix.`;
|
|
45
|
+
}
|
|
46
|
+
return `${all}: the store scope contains tables that are not registered in ${REGISTRY}. Remove them, or choose another tablePrefix.`;
|
|
47
|
+
}
|
|
48
|
+
case "ORM_OWNED_STORE_OWNERSHIP_CONFLICT": {
|
|
49
|
+
for (const store of stores) {
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
if (row.storeKey === store.definition.storeKey) continue;
|
|
52
|
+
if (overlap(store.definition.ownedScope, { schema: row.ownedSchema, tablePrefix: row.tablePrefix })) return `${storeName(store.definition)} overlaps the registered owned store ${dbName(row.storeKey)} (schema ${dbName(row.ownedSchema)}, prefix ${dbName(row.tablePrefix)}); two prefixes overlap when one starts with the other, so "notes_" and "notes_v2_" overlap while "notes_" and "notesv2_" do not.`;
|
|
53
|
+
}
|
|
54
|
+
for (const rejected of store.definition.rejectIfPresent ?? []) {
|
|
55
|
+
const left = tablesIn(rejected);
|
|
56
|
+
if (left.length > 0) return `${storeName(store.definition)} does not start while tables of the rejected scope (schema "${rejected.schema}", prefix "${rejected.tablePrefix}") exist: ${names(left)}. Move their data and drop them, together with their row in ${REGISTRY}.`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return `${all}: the store scope conflicts with another owned store or with a foreign key between different stores.`;
|
|
60
|
+
}
|
|
61
|
+
case "ORM_OWNED_STORE_DRIFT": {
|
|
62
|
+
// Only claim what the snapshots show: an unreadable catalogue is not a changed model.
|
|
63
|
+
if (observed.registry !== undefined && observed.catalogue === undefined) return `${all}: the store scope contains an object bazis does not expect there (for example a table with an unusual name or definition, or one created by another tool), so it was not read. Remove such objects from the scope.`;
|
|
64
|
+
const touched = stores.flatMap((store) => tablesIn(store.definition.ownedScope));
|
|
65
|
+
return `${all}: the tables in the store scope${touched.length > 0 ? ` (${names(touched)})` : ""} differ from the store model; they were changed outside the store. Restore them, or move the data to a new store.`;
|
|
66
|
+
}
|
|
67
|
+
case "ORM_OWNED_STORE_CREATE_FAILED":
|
|
68
|
+
return `${all}: creating the store tables or their ${REGISTRY} rows failed. Check that the database user may create tables in the store schema, then start again.`;
|
|
69
|
+
case "ORM_OWNED_STORE_LOCK_UNAVAILABLE":
|
|
70
|
+
return `${all}: admission did not complete: the database connection failed, the start was cancelled, or another instance held the admission lock. Start again.`;
|
|
71
|
+
case "ORM_OWNED_STORE_PROVIDER_UNSUPPORTED":
|
|
72
|
+
return `${all}: owned stores need PostgreSQL through the shared @Infra connection (ormBazisConnect).`;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
// The code stays first: callers and tests match it in the message text.
|
|
76
|
+
return new OrmOwnedStoreAdmissionError(code, `${code}: ${reason}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FROZEN = "An owned store does not change in place: declare a new storeKey with a tablePrefix that does not overlap the old one, move the data, then drop the old tables and their row in __bazis_orm_owned_stores_v1 (rejectIfPresent can guard against leftovers).";
|
|
80
|
+
|
|
81
|
+
/** Code-side names were validated by defineOrmOwnedStoreV1: bounded, no control characters. */
|
|
82
|
+
function storeName(definition: Readonly<OrmOwnedStoreDefinitionV1>): string {
|
|
83
|
+
return `Owned store "${definition.storeKey}" (schema "${definition.ownedScope.schema}", prefix "${definition.ownedScope.tablePrefix}")`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A database-side identifier: JSON escapes quotes and control characters, so it cannot forge a log line. */
|
|
87
|
+
function dbName(value: string): string {
|
|
88
|
+
const characters = [...value];
|
|
89
|
+
return `${JSON.stringify(characters.slice(0, 63).join(""))}${characters.length > 63 ? "…" : ""}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function dbNumber(value: string): string {
|
|
93
|
+
return /^\d{1,16}$/.test(value) ? value : dbName(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function names(values: readonly string[]): string {
|
|
97
|
+
const shown = values.slice(0, 5).map(dbName).join(", ");
|
|
98
|
+
return values.length > 5 ? `${shown} and ${values.length - 5} more` : shown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function overlap(a: OrmCatalogScopeV1, b: OrmCatalogScopeV1): boolean {
|
|
102
|
+
return a.schema === b.schema && (a.tablePrefix.startsWith(b.tablePrefix) || b.tablePrefix.startsWith(a.tablePrefix));
|
|
103
|
+
}
|
package/library/orm/errors.ts
CHANGED
|
@@ -96,9 +96,14 @@ export class EntityNotFoundError extends OrmError {
|
|
|
96
96
|
|
|
97
97
|
/** The class is not registered as an entity in this context. */
|
|
98
98
|
export class EntityNotMappedError extends OrmError {
|
|
99
|
-
|
|
99
|
+
/** `dynamic`: looked up by name through `setByName`; `plain`: a plain object, not an entity instance. */
|
|
100
|
+
constructor(name: string, kind: "class" | "dynamic" | "plain" = "class") {
|
|
100
101
|
super(
|
|
101
|
-
|
|
102
|
+
kind === "dynamic"
|
|
103
|
+
? `No entity or dynamic model named "${name}" is registered in this DbContext. Register a dynamic table first: options.model.registerModel(buildDynamicModel({ name: "${name}", fields: [...] })).`
|
|
104
|
+
: kind === "plain"
|
|
105
|
+
? `A plain object is not an entity class. For a dynamic table add the row through its set: db.setByName("Table").add(row).`
|
|
106
|
+
: `Type "${name}" is not mapped in this DbContext. Add it to ormModule({ entities: [...] }) and annotate it with @Entity().`,
|
|
102
107
|
);
|
|
103
108
|
}
|
|
104
109
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.24",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|