bazis 0.98.21 → 0.98.23
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/di/errors/NamedDependencyNotFoundError.ts +12 -1
- package/core/di/module/encapsulation.ts +36 -3
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/startupFailure.ts +3 -0
- package/core/orm/encapsulationHook.ts +11 -1
- package/core/orm/ormModule.ts +8 -2
- package/core/orm/ownedStoreContributions.ts +27 -15
- package/library/orm/Repository/README.md +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/package.json +1 -1
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { DiError } from "./DiError";
|
|
2
2
|
|
|
3
|
+
/** Returns a hint for a named dependency nobody registers, or `undefined`. */
|
|
4
|
+
export type NamedDependencyHint = (name: string) => string | undefined;
|
|
5
|
+
|
|
6
|
+
const hints: NamedDependencyHint[] = [];
|
|
7
|
+
|
|
8
|
+
/** Infrastructure extensions (ORM repositories, …) explain their own named dependencies here. */
|
|
9
|
+
export function registerNamedDependencyHint(hint: NamedDependencyHint): void {
|
|
10
|
+
hints.push(hint);
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export class NamedDependencyNotFoundError extends DiError {
|
|
4
14
|
public constructor(name: string) {
|
|
5
|
-
|
|
15
|
+
const hint = hints.map((item) => item(name)).find((text) => text !== undefined);
|
|
16
|
+
super(`No provider token found for named dependency "${name}".${hint ? ` ${hint}` : ""}`);
|
|
6
17
|
}
|
|
7
18
|
}
|
|
@@ -84,7 +84,9 @@ export function validateModuleEncapsulation(
|
|
|
84
84
|
if (isFamilyExport(ref)) {
|
|
85
85
|
continue;
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
// A closed token of a visible family (`repositoryFor(Product)` next to an
|
|
88
|
+
// `ormBazis` that exports the whole `IRepository` family) may be passed on alone.
|
|
89
|
+
if (record.providedTokens.has(ref) || visible.has(ref) || (typeof ref !== "function" && ref.genericFamilyId !== undefined && allProvidedTokens.has(ref) && visibleFamilies(record).has(ref.genericFamilyId))) {
|
|
88
90
|
result.add(ref);
|
|
89
91
|
} else {
|
|
90
92
|
issues.push(
|
|
@@ -173,6 +175,10 @@ export function validateModuleEncapsulation(
|
|
|
173
175
|
// Why a provided token is not visible: the owner does not export it, or the
|
|
174
176
|
// consumer module does not import the owner. Both used to read "not exported".
|
|
175
177
|
const explainHidden = (token: Token<unknown>, consumer: ModuleGraphRecord): string => {
|
|
178
|
+
if (typeof token !== "function" && token.genericFamilyId !== undefined) {
|
|
179
|
+
const family = explainHiddenFamily(token, token.genericFamilyId, consumer);
|
|
180
|
+
if (family !== undefined) return family;
|
|
181
|
+
}
|
|
176
182
|
// A module the consumer imports already sees the token but does not pass it
|
|
177
183
|
// on. That export is the fix, whatever inner module (an `ormBazis` feature
|
|
178
184
|
// module, say) registered the token.
|
|
@@ -188,6 +194,31 @@ export function validateModuleEncapsulation(
|
|
|
188
194
|
: `which module "${owner.name}" provides but does not export. Add it to the exports of "${owner.name}".`;
|
|
189
195
|
};
|
|
190
196
|
|
|
197
|
+
// A closed token (`IRepository<Product>`) travels with its family export, so
|
|
198
|
+
// the fix is named in family terms and skips unnamed inner modules such as
|
|
199
|
+
// the one `ormBazis` creates.
|
|
200
|
+
const explainHiddenFamily = (token: Token<unknown>, familyId: symbol, consumer: ModuleGraphRecord): string | undefined => {
|
|
201
|
+
const closed = tokenToDebugName(token);
|
|
202
|
+
const family = closed.includes("<") ? closed.slice(0, closed.indexOf("<")) : closed;
|
|
203
|
+
const exportFix = `${family} (or only ${closed})`;
|
|
204
|
+
const sees = (record: ModuleGraphRecord) => visibleFamilies(record).has(familyId) || visibleTokens(record).has(token);
|
|
205
|
+
const passes = (record: ModuleGraphRecord) => exportedFamilies(record).has(familyId) || exportedTokens(record).has(token);
|
|
206
|
+
const named = (record: ModuleGraphRecord) => !/^module#\d+$/.test(record.name);
|
|
207
|
+
const blocking = consumer.imports.find((imported) => named(imported) && sees(imported) && !passes(imported));
|
|
208
|
+
if (blocking !== undefined) {
|
|
209
|
+
return `which its import "${blocking.name}" receives from its own imports but does not export. Add ${exportFix} to the exports of "${blocking.name}".`;
|
|
210
|
+
}
|
|
211
|
+
const exporter = records.find((record) => record !== consumer && named(record) && passes(record));
|
|
212
|
+
if (exporter !== undefined) {
|
|
213
|
+
return `which module "${exporter.name}" exports, but "${consumer.name}" does not list "${exporter.name}" in its imports. Add "${exporter.name}" to the imports of "${consumer.name}".`;
|
|
214
|
+
}
|
|
215
|
+
const host = records.find((record) => record !== consumer && named(record) && sees(record));
|
|
216
|
+
if (host !== undefined) {
|
|
217
|
+
return `which module "${host.name}" receives from its own imports but does not export. Add ${exportFix} to the exports of "${host.name}" and "${host.name}" to the imports of "${consumer.name}".`;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
};
|
|
221
|
+
|
|
191
222
|
const contexts = new Map<ModuleGraphRecord, DependencyCheckContext>();
|
|
192
223
|
for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) {
|
|
193
224
|
const record = records[recordIndex] as ModuleGraphRecord;
|
|
@@ -302,7 +333,7 @@ function checkDependency(
|
|
|
302
333
|
!context.familiesVisible.has(token.genericFamilyId)
|
|
303
334
|
) {
|
|
304
335
|
context.issues.push(
|
|
305
|
-
`Module "${record.name}": "${consumer}" depends on
|
|
336
|
+
`Module "${record.name}": "${consumer}" depends on "${tokenToDebugName(token)}", ${context.explainHidden(token, record)}`,
|
|
306
337
|
);
|
|
307
338
|
}
|
|
308
339
|
return;
|
|
@@ -363,6 +394,7 @@ export function computeModuleVisibleTokens(
|
|
|
363
394
|
): Map<ModuleGraphRecord, Set<Token<unknown>>> {
|
|
364
395
|
const exportedMemo = new Map<ModuleGraphRecord, Set<Token<unknown>>>();
|
|
365
396
|
const visiting = new Set<ModuleGraphRecord>();
|
|
397
|
+
const allProvided = new Set<Token<unknown>>(records.flatMap((record) => [...record.providedTokens]));
|
|
366
398
|
|
|
367
399
|
const visibleOf = (record: ModuleGraphRecord): Set<Token<unknown>> => {
|
|
368
400
|
const result = new Set<Token<unknown>>(record.providedTokens);
|
|
@@ -401,7 +433,8 @@ export function computeModuleVisibleTokens(
|
|
|
401
433
|
if (isFamilyExport(ref)) {
|
|
402
434
|
continue;
|
|
403
435
|
}
|
|
404
|
-
|
|
436
|
+
// Validation already admitted closed tokens re-exported from a visible family.
|
|
437
|
+
if (record.providedTokens.has(ref) || visible.has(ref) || (typeof ref !== "function" && ref.genericFamilyId !== undefined && allProvided.has(ref))) {
|
|
405
438
|
result.add(ref);
|
|
406
439
|
}
|
|
407
440
|
}
|
|
@@ -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.23" }, 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];
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { registerNamedDependencyEncapsulationHook } from "../di/module/encapsulationHooks";
|
|
2
|
+
import { registerNamedDependencyHint } from "../di/errors/NamedDependencyNotFoundError";
|
|
2
3
|
import { IRepository } from "./repository";
|
|
3
4
|
|
|
4
5
|
let registered = false;
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Allows closed `IRepository<Entity>` named deps when the module exports open
|
|
9
|
+
* generic `IRepository`, and explains a repository nobody registers.
|
|
10
|
+
*/
|
|
7
11
|
export function registerRepositoryEncapsulationHook(): void {
|
|
8
12
|
if (registered) {
|
|
9
13
|
return;
|
|
@@ -16,4 +20,10 @@ export function registerRepositoryEncapsulationHook(): void {
|
|
|
16
20
|
}
|
|
17
21
|
return undefined;
|
|
18
22
|
});
|
|
23
|
+
|
|
24
|
+
registerNamedDependencyHint((name) => {
|
|
25
|
+
const match = /^IRepository<(.+)>$/.exec(name);
|
|
26
|
+
if (!match) return undefined;
|
|
27
|
+
return `Repositories are registered by ormBazis for the entities of its context: add ${match[1]} to the entities of an ormBazis module and keep registerRepositories on (the default), or inject that module's DbContext instead.`;
|
|
28
|
+
});
|
|
19
29
|
}
|
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);
|
|
@@ -82,6 +82,9 @@ const DataModule = ormModule({
|
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
`ormModule` exports the `IRepository` family token so importing modules can inject `repositoryFor(User)`.
|
|
85
|
+
A feature module that declares `ormBazis` passes repositories on with its own
|
|
86
|
+
`exports`: `exports: [IRepository]` opens the repositories of all its entities,
|
|
87
|
+
`exports: [repositoryFor(User)]` (since 0.98.22) only that one.
|
|
85
88
|
|
|
86
89
|
Turning repositories off:
|
|
87
90
|
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.23",
|
|
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",
|