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.
@@ -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.22" }, capabilities: { experimentalApi: true },
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];
@@ -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 || config.provider || config.ensureCreated || config.migrateOnStart || config.runMigrationsOnStart || config.migrations !== undefined) {
114
- throw new OrmError("ORM owned store requires a context and entities and cannot compose startup schema or migration authority.");
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
- function fail(code: "ORM_OWNED_STORE_IDENTITY_MISMATCH" | "ORM_OWNED_STORE_OWNERSHIP_CONFLICT"): never {
45
- throw new OrmOwnedStoreAdmissionError(code, code);
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
- if ((record.definition.rejectIfPresent ?? []).some((scope) => overlaps(record.definition.ownedScope, scope))) {
123
- fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
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) if (other !== record && (overlaps(record.definition.ownedScope, other.definition.ownedScope) || (other.definition.rejectIfPresent ?? []).some((scope) => overlaps(record.definition.ownedScope, scope)))) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
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) || table.schema !== record.definition.ownedScope.schema || !table.table.startsWith(record.definition.ownedScope.tablePrefix)) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
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 ((owner && owner !== entry.owned) || rejected) fail("ORM_OWNED_STORE_OWNERSHIP_CONFLICT");
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);
@@ -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 multiple keys ("${keys
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 || !validString(storeKey, 128) || typeof formatVersion !== "number" || !Number.isSafeInteger(formatVersion) || formatVersion <= 0) throw identity();
36
- const ownedScope = readScope(root.ownedScope);
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
- if (!allowed.includes(key)) throw identity();
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
- function identity(): OrmOwnedStoreAdmissionError { return new OrmOwnedStoreAdmissionError(IDENTITY_MISMATCH, IDENTITY_MISMATCH); }
122
- function ownership(): OrmOwnedStoreAdmissionError { return new OrmOwnedStoreAdmissionError(OWNERSHIP_CONFLICT, OWNERSHIP_CONFLICT); }
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
+ }
@@ -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
- constructor(name: string) {
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
- `Type "${name}" is not mapped in this DbContext. Add it to ormModule({ entities: [...] }) and annotate it with @Entity().`,
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.22",
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",