@proteinjs/db 1.49.1 → 1.50.1

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.
@@ -0,0 +1,337 @@
1
+ // EnvInfo.isDev() requires a GlobalDataStorage implementation that only exists in a running app
2
+ // (the same mock the containment suite uses).
3
+ jest.mock('@proteinjs/server-api', () => ({
4
+ EnvInfo: { isDev: () => true },
5
+ }));
6
+
7
+ /**
8
+ * The world the runner sees, planted per test: the system db (a ledger in memory — the runner's
9
+ * every read and write lands here, so writes are OBSERVABLE) and the build's declared migrations
10
+ * (the reflection source-record loaders for the migration table, which this harness has no
11
+ * reflection graph to serve).
12
+ */
13
+ const mockWorld = {
14
+ db: undefined as unknown as FakeLedgerDb,
15
+ loaders: [] as { loader: { table: { name: string }; record: unknown } }[],
16
+ };
17
+ jest.mock('../src/Db', () => ({
18
+ ...jest.requireActual('../src/Db'),
19
+ getDbAsSystem: () => mockWorld.db,
20
+ }));
21
+ jest.mock('../src/source/SourceRecord', () => ({
22
+ ...jest.requireActual('../src/source/SourceRecord'),
23
+ getSourceRecordLoaders: () => mockWorld.loaders,
24
+ }));
25
+
26
+ import { Logger, Log, DefaultLogWriter } from '@proteinjs/logger';
27
+ import { MigrationRunner } from '../src/MigrationRunner';
28
+ import { Migration, MigrationEstimate, MigrationTable } from '../src/tables/MigrationTable';
29
+ import { SourceRecordRepo } from '../src/source/SourceRecordRepo';
30
+
31
+ /**
32
+ * The plan before the run (the estimate door): every pending migration's estimate is logged as
33
+ * one line BEFORE the series' first run, a migration without an estimate says so, and the dry run
34
+ * (`planPendingMigrations`) reads the estimates and applies nothing — no run, no ledger write.
35
+ *
36
+ * Pinned on OUTCOMES: the order of events (log lines vs. run effects) in one shared list, the
37
+ * writes the fake ledger received, the plan's own numbers.
38
+ */
39
+
40
+ /** A migration ledger in memory, with the reads and writes the runner makes. */
41
+ class FakeLedgerDb {
42
+ rows: Migration[] = [];
43
+ writes: Partial<Migration>[] = [];
44
+ exists = true;
45
+ async tableExists(): Promise<boolean> {
46
+ return this.exists;
47
+ }
48
+ async query(): Promise<Migration[]> {
49
+ return this.rows.map((row) => ({ ...row }));
50
+ }
51
+ async get(_table: unknown, { id }: { id: string }): Promise<Migration | undefined> {
52
+ const row = this.rows.find((candidate) => candidate.id === id);
53
+ return row ? { ...row } : undefined;
54
+ }
55
+ async update(_table: unknown, partial: Partial<Migration>): Promise<void> {
56
+ this.writes.push(partial);
57
+ const row = this.rows.find((candidate) => candidate.id === partial.id);
58
+ if (row) {
59
+ Object.assign(row, partial);
60
+ }
61
+ }
62
+ }
63
+
64
+ type RunnerInternals = { logger: Logger };
65
+
66
+ describe('MigrationRunner — the plan before the run', () => {
67
+ const migrationTable = new MigrationTable();
68
+ let events: string[];
69
+ let db: FakeLedgerDb;
70
+ let plantedIds: string[];
71
+
72
+ /** A source declaration registered in this process (what init's source sync registers). */
73
+ const plant = (
74
+ id: string,
75
+ overrides: Partial<Migration> & { estimate?: () => Promise<MigrationEstimate> } = {}
76
+ ): Migration => {
77
+ const migration = {
78
+ id,
79
+ description: `plan test migration ${id}`,
80
+ run: async () => {
81
+ events.push(`run:${id}`);
82
+ return `${id} output`;
83
+ },
84
+ ...overrides,
85
+ } as Migration;
86
+ new SourceRecordRepo().loadSourceRecord(migrationTable.name, migration);
87
+ plantedIds.push(id);
88
+ return migration;
89
+ };
90
+
91
+ /** A ledger row for a planted migration, as the source sync lands it. */
92
+ const ledgerRow = (
93
+ migration: Migration,
94
+ status: Migration['status'] = 'proposed',
95
+ extra: Partial<Migration> = {}
96
+ ) => {
97
+ db.rows.push({ id: migration.id, description: migration.description, status, ...extra } as Migration);
98
+ };
99
+
100
+ const runner = () => {
101
+ const instance = new MigrationRunner();
102
+ (instance as unknown as RunnerInternals).logger = new Logger({
103
+ name: 'MigrationRunner',
104
+ logWriter: {
105
+ write: (log: Log) => {
106
+ events.push(`log:${log.message}`);
107
+ },
108
+ } as unknown as DefaultLogWriter,
109
+ });
110
+ return instance;
111
+ };
112
+
113
+ const planLines = () => events.filter((event) => event.startsWith('log:Migration plan'));
114
+
115
+ beforeEach(() => {
116
+ events = [];
117
+ db = new FakeLedgerDb();
118
+ plantedIds = [];
119
+ mockWorld.db = db;
120
+ mockWorld.loaders = [];
121
+ });
122
+
123
+ afterEach(() => {
124
+ // The repo is a process-wide static: unregister what this test planted so ids never leak
125
+ // between tests (a stale registration would resolve a later test's ghost row).
126
+ const repo = new SourceRecordRepo() as unknown as { constructor: { SOURCE_RECORD_MAP: Record<string, unknown> } };
127
+ for (const id of plantedIds) {
128
+ delete repo.constructor.SOURCE_RECORD_MAP[`${migrationTable.name}:${id}`];
129
+ }
130
+ });
131
+
132
+ it('logs each pending migration’s plan line and the total BEFORE the first run, and the series still runs', async () => {
133
+ const estimated = plant('plan-a-estimated', {
134
+ estimate: async () => ({
135
+ units: 40,
136
+ unit: 'roots',
137
+ secondsPerUnit: 0.3,
138
+ note: 'measured on a rehearsal',
139
+ }),
140
+ });
141
+ const unestimated = plant('plan-b-unestimated');
142
+ ledgerRow(estimated);
143
+ ledgerRow(unestimated);
144
+
145
+ const summary = await runner().runPendingMigrations();
146
+
147
+ expect(summary.applied).toEqual(['plan-a-estimated', 'plan-b-unestimated']);
148
+ expect(planLines()).toEqual([
149
+ 'log:Migration plan: (plan-a-estimated) plan test migration plan-a-estimated — 40 roots, ≈ 12 s at 0.3 s per unit (measured on a rehearsal)',
150
+ 'log:Migration plan: (plan-b-unestimated) plan test migration plan-b-unestimated — no estimate declared',
151
+ 'log:Migration plan total: 2 pending, ≈ 12 s projected over 1 of them, 1 without a projection (plan-b-unestimated)',
152
+ ]);
153
+ // Every plan line lands before the FIRST run effect — the reader knows the whole plan before
154
+ // a row changes.
155
+ const firstRun = events.indexOf('run:plan-a-estimated');
156
+ expect(firstRun).toBeGreaterThan(-1);
157
+ for (const line of planLines()) {
158
+ expect(events.indexOf(line)).toBeLessThan(firstRun);
159
+ }
160
+ expect(events.indexOf('run:plan-b-unestimated')).toBeGreaterThan(firstRun);
161
+ });
162
+
163
+ it('the dry run reads the estimates and applies nothing — no run, no ledger write, an unresolved row reported not stamped', async () => {
164
+ let estimateReads = 0;
165
+ const pendingA = plant('dry-a', {
166
+ estimate: async () => {
167
+ estimateReads++;
168
+ return { units: 3, unit: 'rows', secondsPerUnit: 2 };
169
+ },
170
+ });
171
+ const pendingB = plant('dry-b', {
172
+ estimate: async () => {
173
+ estimateReads++;
174
+ return { units: 10, unit: 'rows', secondsPerUnit: 0.5 };
175
+ },
176
+ });
177
+ const counted = plant('dry-c-counted-only', {
178
+ estimate: async () => ({ units: 5, unit: 'items', note: 'no timing yet' }),
179
+ });
180
+ const applied = plant('dry-d-applied', { estimate: async () => ({ units: 999, unit: 'rows', secondsPerUnit: 9 }) });
181
+ const manual = plant('dry-e-manual', { manual: true } as Partial<Migration>);
182
+ const retired = plant('dry-f-retired');
183
+ ledgerRow(pendingA);
184
+ ledgerRow(pendingB, 'failure');
185
+ ledgerRow(counted, 'running');
186
+ ledgerRow(applied, 'success');
187
+ ledgerRow(manual);
188
+ ledgerRow(retired, 'proposed', { retired: true });
189
+ db.rows.push({ id: 'dry-g-ghost', description: 'loader deleted after an old release ran it' } as Migration);
190
+
191
+ const plan = await runner().planPendingMigrations();
192
+
193
+ expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
194
+ expect(db.writes).toEqual([]);
195
+ expect(estimateReads).toBe(2);
196
+ expect(plan.pending.map((entry) => entry.id)).toEqual(['dry-a', 'dry-b', 'dry-c-counted-only']);
197
+ expect(plan.pending[0]).toEqual({
198
+ id: 'dry-a',
199
+ description: 'plan test migration dry-a',
200
+ estimate: { units: 3, unit: 'rows', secondsPerUnit: 2 },
201
+ projectedSeconds: 6,
202
+ });
203
+ expect(plan.pending[1].projectedSeconds).toBe(5);
204
+ expect(plan.pending[2]).toEqual({
205
+ id: 'dry-c-counted-only',
206
+ description: 'plan test migration dry-c-counted-only',
207
+ estimate: { units: 5, unit: 'items', note: 'no timing yet' },
208
+ unprojectedReason: 'no seconds per unit declared',
209
+ });
210
+ expect(plan.projectedSeconds).toBe(11);
211
+ expect(plan.projected).toBe(2);
212
+ expect(plan.unprojected).toEqual(['dry-c-counted-only']);
213
+ expect(plan.alreadyApplied).toEqual(['dry-d-applied']);
214
+ expect(plan.skippedManual).toEqual(['dry-e-manual']);
215
+ expect(plan.retired).toEqual(['dry-f-retired']);
216
+ expect(plan.unresolved).toEqual(['dry-g-ghost']);
217
+ expect(plan.freshDatabase).toBe(false);
218
+ expect(planLines()).toEqual([
219
+ 'log:Migration plan: (dry-a) plan test migration dry-a — 3 rows, ≈ 6 s at 2 s per unit',
220
+ 'log:Migration plan: (dry-b) plan test migration dry-b — 10 rows, ≈ 5 s at 0.5 s per unit',
221
+ 'log:Migration plan: (dry-c-counted-only) plan test migration dry-c-counted-only — 5 items, no seconds per unit declared (no timing yet)',
222
+ 'log:Migration plan total: 3 pending, ≈ 11 s projected over 2 of them, 1 without a projection (dry-c-counted-only)',
223
+ ]);
224
+ });
225
+
226
+ it('plans a migration the build declares that has no ledger row yet — after the ledger’s pending rows, in id order', async () => {
227
+ const inLedger = plant('new-0-in-ledger', {
228
+ estimate: async () => ({ units: 1, unit: 'rows', secondsPerUnit: 1 }),
229
+ });
230
+ ledgerRow(inLedger);
231
+ const declaredOnly = (id: string, estimate?: () => Promise<MigrationEstimate>) => ({
232
+ loader: {
233
+ table: { name: migrationTable.name },
234
+ record: { id, description: `declared ${id}`, run: async () => events.push(`run:${id}`), estimate },
235
+ },
236
+ });
237
+ mockWorld.loaders = [
238
+ declaredOnly('new-z-later', async () => ({ units: 4, unit: 'rows', secondsPerUnit: 10, note: 'new this build' })),
239
+ declaredOnly('new-m-manual-later'),
240
+ {
241
+ loader: {
242
+ table: { name: 'some_other_source_table' },
243
+ record: { id: 'not-a-migration', run: async () => undefined },
244
+ },
245
+ },
246
+ declaredOnly('new-b-earlier'),
247
+ ];
248
+ (mockWorld.loaders[1].loader.record as Migration).manual = true;
249
+ (mockWorld.loaders[1].loader.record as Migration).preSchemaSync = false;
250
+ (mockWorld.loaders[3].loader.record as Migration).preSchemaSync = true;
251
+
252
+ const plan = await runner().planPendingMigrations();
253
+
254
+ expect(plan.pending.map((entry) => entry.id)).toEqual(['new-0-in-ledger', 'new-b-earlier', 'new-z-later']);
255
+ expect(plan.pending[1]).toEqual({
256
+ id: 'new-b-earlier',
257
+ description: 'declared new-b-earlier',
258
+ preSchemaSync: true,
259
+ unprojectedReason: 'no estimate declared',
260
+ });
261
+ expect(plan.pending[2].projectedSeconds).toBe(40);
262
+ expect(plan.projectedSeconds).toBe(41);
263
+ expect(plan.skippedManual).toEqual(['new-m-manual-later']);
264
+ expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
265
+ expect(db.writes).toEqual([]);
266
+ expect(planLines()[1]).toBe(
267
+ 'log:Migration plan: (new-b-earlier) declared new-b-earlier [runs at init, before schema sync] — no estimate declared'
268
+ );
269
+ });
270
+
271
+ it('on a fresh database (no ledger table) lists every declared migration and reads no estimate', async () => {
272
+ db.exists = false;
273
+ mockWorld.loaders = [
274
+ {
275
+ loader: {
276
+ table: { name: migrationTable.name },
277
+ record: {
278
+ id: 'fresh-a',
279
+ description: 'declared fresh-a',
280
+ run: async () => events.push('run:fresh-a'),
281
+ estimate: async () => {
282
+ throw new Error('the estimate must not be read on a fresh database — its tables do not exist');
283
+ },
284
+ },
285
+ },
286
+ },
287
+ ];
288
+
289
+ const plan = await runner().planPendingMigrations();
290
+
291
+ expect(plan.freshDatabase).toBe(true);
292
+ expect(plan.pending).toEqual([
293
+ { id: 'fresh-a', description: 'declared fresh-a', unprojectedReason: 'fresh database, nothing to count' },
294
+ ]);
295
+ expect(plan.projectedSeconds).toBe(0);
296
+ expect(plan.unprojected).toEqual(['fresh-a']);
297
+ expect(planLines()).toEqual([
298
+ 'log:Migration plan: (fresh-a) declared fresh-a — fresh database, nothing to count',
299
+ 'log:Migration plan total: 1 pending, ≈ 0 s projected over 0 of them, 1 without a projection (fresh-a)',
300
+ ]);
301
+ expect(db.writes).toEqual([]);
302
+ });
303
+
304
+ it('an estimate that throws fails the plan loudly — a plan that cannot count is not a plan', async () => {
305
+ const broken = plant('broken-estimate', {
306
+ estimate: async () => {
307
+ throw new Error('count query failed');
308
+ },
309
+ });
310
+ ledgerRow(broken);
311
+
312
+ await expect(runner().planPendingMigrations()).rejects.toThrow('count query failed');
313
+ expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
314
+ expect(db.writes).toEqual([]);
315
+ });
316
+
317
+ it('a throwing estimate names the migration in the failure — the reader of the plan Job’s log knows which count failed', async () => {
318
+ const fine = plant('names-a-fine', { estimate: async () => ({ units: 1, unit: 'rows', secondsPerUnit: 1 }) });
319
+ const broken = plant('names-b-broken', {
320
+ estimate: async () => {
321
+ throw new Error('count query failed');
322
+ },
323
+ });
324
+ ledgerRow(fine);
325
+ ledgerRow(broken);
326
+
327
+ await expect(runner().planPendingMigrations()).rejects.toThrow(
328
+ 'Migration plan: (names-b-broken) plan test migration names-b-broken — the estimate failed: count query failed'
329
+ );
330
+ expect(events.filter((event) => event.startsWith('run:'))).toEqual([]);
331
+ expect(db.writes).toEqual([]);
332
+ // The line for the migration before it landed; no line for the one that could not count.
333
+ expect(planLines()).toEqual([
334
+ 'log:Migration plan: (names-a-fine) plan test migration names-a-fine — 1 rows, ≈ 1 s at 1 s per unit',
335
+ ]);
336
+ });
337
+ });
@@ -11,8 +11,9 @@ import { TableAuth } from '../src/auth/TableAuth';
11
11
  * - each identity form resolves against the current user; 'admin' is break-glass throughout;
12
12
  * - an UNDECLARED identity is admin-only (default-deny made explicit), never open;
13
13
  * - it fails CLOSED when no user can be resolved;
14
- * - the operation doors (`canPerform`) are byte-for-byte the pre-refactor semantics: within a
15
- * declared block an undeclared operation stays closed to everyone, admin included.
14
+ * - the operation doors (`canPerform`) keep the declared-block semantics: within a declared block
15
+ * an undeclared operation stays closed to everyone, admin included — unless the table's SERVICE
16
+ * block names that door, which the db door then mirrors (the last describe).
16
17
  *
17
18
  * `UserAuth` reads from a static repo; tests stub it directly per identity — no server needed.
18
19
  */
@@ -113,3 +114,95 @@ describe('TableAuth operation doors keep their declared-block semantics', () =>
113
114
  expect(new TableAuth().canPerform(new QueryOnlyTable(), 'insert')).toBe(false);
114
115
  });
115
116
  });
117
+
118
+ /**
119
+ * The RPC runs BOTH doors — the service gate (`TableServiceAuth`) and then the inner `Db`'s
120
+ * db-api re-check as the calling user — so a db door a table leaves UNDECLARED mirrors the door
121
+ * its service block declares for the same operation. "db doors mirror service doors" was a rule
122
+ * tables hand-copied; the owner derives it now: fifteen consumer tables opened `['admin']` writes
123
+ * on the service api over a query-only db block, and every one refused the admin at the inner
124
+ * check in the gate's own words — the gate admitted them, the re-check behind it did not, two
125
+ * verdicts for one caller. One-way: a service block that omits the door keeps the lock (the
126
+ * session / audit-trail pattern — no write door on either api, break-glass included), and a table
127
+ * with no service block keeps its db block as written (the pin above).
128
+ */
129
+ class ReadOpenWriteAdminTable extends Table<Doc> {
130
+ public name = 'identity_read_open_write_admin_test';
131
+ public auth: Table<Doc>['auth'] = {
132
+ db: { query: 'authenticated' },
133
+ service: { query: 'authenticated', insert: ['admin'], update: ['admin'], delete: ['admin'] },
134
+ };
135
+ public columns = withRecordColumns<Doc>({ title: new StringColumn('title') });
136
+ }
137
+
138
+ class LockedLedgerTable extends Table<Doc> {
139
+ public name = 'identity_locked_ledger_test';
140
+ public auth: Table<Doc>['auth'] = {
141
+ db: { query: { permission: 'ledger' } },
142
+ service: { query: { permission: 'ledger' } },
143
+ };
144
+ public columns = withRecordColumns<Doc>({ title: new StringColumn('title') });
145
+ }
146
+
147
+ describe('TableAuth — an undeclared db door mirrors the declared service door', () => {
148
+ beforeEach(() => {
149
+ setMapping({ ledger: ['ledger'] });
150
+ });
151
+
152
+ afterEach(() => {
153
+ (UserAuth as any).userRepo = undefined;
154
+ (UserAuth as any).permissionRolesMapping = undefined;
155
+ });
156
+
157
+ test("an admin's write clears the ['admin'] service door AND the db-api re-check behind it", () => {
158
+ setUser(['admin']);
159
+ const table = new ReadOpenWriteAdminTable();
160
+ expect(() => new TableAuth().canUpdate(table, 'service')).not.toThrow();
161
+ expect(() => new TableAuth().canUpdate(table)).not.toThrow();
162
+ expect(() => new TableAuth().canInsert(table)).not.toThrow();
163
+ expect(() => new TableAuth().canDelete(table)).not.toThrow();
164
+ expect(new TableAuth().canPerform(table, 'update', 'db')).toBe(true);
165
+ });
166
+
167
+ test('a signed-in non-admin is refused by name on both apis; the read door stays open', () => {
168
+ setUser(['ledger']);
169
+ const table = new ReadOpenWriteAdminTable();
170
+ expect(() => new TableAuth().canUpdate(table, 'service')).toThrow(
171
+ 'User is not authorized to update records in table: identity_read_open_write_admin_test'
172
+ );
173
+ expect(() => new TableAuth().canUpdate(table)).toThrow(
174
+ 'User is not authorized to update records in table: identity_read_open_write_admin_test'
175
+ );
176
+ expect(() => new TableAuth().canQuery(table)).not.toThrow();
177
+ });
178
+
179
+ test('a guest is refused every door', () => {
180
+ setGuest();
181
+ const table = new ReadOpenWriteAdminTable();
182
+ expect(() => new TableAuth().canQuery(table)).toThrow(
183
+ 'User is not authorized to query table: identity_read_open_write_admin_test'
184
+ );
185
+ expect(() => new TableAuth().canUpdate(table)).toThrow();
186
+ expect(() => new TableAuth().canInsert(table, 'service')).toThrow();
187
+ });
188
+
189
+ test('one-way: a service block that omits the door keeps the lock on both apis, break-glass included', () => {
190
+ setUser(['admin']);
191
+ const table = new LockedLedgerTable();
192
+ expect(new TableAuth().canPerform(table, 'update', 'service')).toBe(false);
193
+ expect(new TableAuth().canPerform(table, 'update', 'db')).toBe(false);
194
+ expect(() => new TableAuth().canUpdate(table)).toThrow(
195
+ 'User is not authorized to update records in table: identity_locked_ledger_test'
196
+ );
197
+ expect(() => new TableAuth().canInsert(table, 'service')).toThrow();
198
+ expect(() => new TableAuth().canQuery(table)).not.toThrow();
199
+ });
200
+
201
+ test('a table with no service block keeps its db block as written — admin included', () => {
202
+ setUser(['admin']);
203
+ expect(new TableAuth().canPerform(new QueryOnlyTable(), 'update', 'db')).toBe(false);
204
+ expect(() => new TableAuth().canUpdate(new QueryOnlyTable())).toThrow(
205
+ 'User is not authorized to update records in table: identity_query_only_test'
206
+ );
207
+ });
208
+ });