@proteinjs/db 1.43.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +3 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Table.d.ts +37 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js.map +1 -1
- package/dist/src/auth/TableAuth.d.ts +11 -1
- package/dist/src/auth/TableAuth.d.ts.map +1 -1
- package/dist/src/auth/TableAuth.js +39 -13
- package/dist/src/auth/TableAuth.js.map +1 -1
- package/dist/src/source/SourceRecord.d.ts +2 -3
- package/dist/src/source/SourceRecord.d.ts.map +1 -1
- package/dist/src/source/SourceRecord.js +16 -2
- package/dist/src/source/SourceRecord.js.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +12 -0
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +72 -50
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +22 -4
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/src/tables/MigrationTable.js +38 -16
- package/dist/src/tables/MigrationTable.js.map +1 -1
- package/dist/test/SourceRecordLoader.test.js +151 -0
- package/dist/test/SourceRecordLoader.test.js.map +1 -1
- package/dist/test/TableAuthIdentity.test.d.ts +2 -0
- package/dist/test/TableAuthIdentity.test.d.ts.map +1 -0
- package/dist/test/TableAuthIdentity.test.js +110 -0
- package/dist/test/TableAuthIdentity.test.js.map +1 -0
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +124 -0
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +17 -0
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js +26 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +3 -1
- package/package.json +2 -2
- package/src/Table.ts +38 -1
- package/src/auth/TableAuth.ts +45 -20
- package/src/source/SourceRecord.ts +17 -4
- package/src/source/SourceRecordLoader.ts +30 -3
- package/src/tables/MigrationTable.ts +43 -16
- package/test/SourceRecordLoader.test.ts +90 -0
- package/test/TableAuthIdentity.test.ts +115 -0
- package/test/reusable/SourceRecordSyncTests.ts +72 -0
- package/test/util/tables/sourceRecordSyncTestTables.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.45.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "135045a9e4b16a75ea11f610a82fa7ff60751e8d"
|
|
70
70
|
}
|
package/src/Table.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
|
2
2
|
import { CustomSerializableObject } from '@proteinjs/serializer';
|
|
3
3
|
import { isRecordColumn, Record } from './Record';
|
|
4
4
|
import { TableSerializerId } from './serializers/TableSerializer';
|
|
5
|
-
import { ColumnQueryTransform, QueryBuilder } from '@proteinjs/db-query';
|
|
5
|
+
import { ColumnQueryTransform, QueryBuilder, SortCriteria } from '@proteinjs/db-query';
|
|
6
6
|
import { Identity, TableOperationsAuth } from './auth/TableAuth';
|
|
7
7
|
import { Db } from './Db';
|
|
8
8
|
import { EncryptionDerivedTableRegistry } from './encryption/EncryptionDerivedTableRegistry';
|
|
@@ -132,6 +132,13 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
|
|
|
132
132
|
* form still carries the full record).
|
|
133
133
|
*/
|
|
134
134
|
columns?: (keyof T & string)[];
|
|
135
|
+
/**
|
|
136
|
+
* The record table's default ordering (the sort its default loader applies before any
|
|
137
|
+
* user interaction). Undeclared tables keep the generic `updated` descending. Declare
|
|
138
|
+
* when the rows have a natural reading order the shared face can't know — e.g. the
|
|
139
|
+
* migration ledger reads most-recently-run first, never-run rows last.
|
|
140
|
+
*/
|
|
141
|
+
sort?: SortCriteria<T>[];
|
|
135
142
|
};
|
|
136
143
|
};
|
|
137
144
|
public auth?: {
|
|
@@ -330,6 +337,14 @@ export type ColumnOptions = {
|
|
|
330
337
|
) => Promise<void>;
|
|
331
338
|
ui?: {
|
|
332
339
|
hidden?: boolean;
|
|
340
|
+
/**
|
|
341
|
+
* The column's display label on the generic record surfaces (table header, form field,
|
|
342
|
+
* phone card label). Undeclared, the surfaces humanize the property name (`startTime` →
|
|
343
|
+
* "Start time"); declare when the property name is not how a human reads the value
|
|
344
|
+
* (the migration ledger's `startTime` reads as "Ran at"). One owner: both surfaces derive
|
|
345
|
+
* from it, so a label can never differ between the table and the form.
|
|
346
|
+
*/
|
|
347
|
+
label?: string;
|
|
333
348
|
/**
|
|
334
349
|
* Which section of the record form this column belongs to. The form derives a sane
|
|
335
350
|
* section from the column's type and name (identity strings up top, long text and
|
|
@@ -342,7 +357,29 @@ export type ColumnOptions = {
|
|
|
342
357
|
};
|
|
343
358
|
};
|
|
344
359
|
|
|
360
|
+
/**
|
|
361
|
+
* The identity of one source-record declaration (a `SourceRecordLoader` in a build): the
|
|
362
|
+
* package that owns it, the reflection qualified name, and the declaration's own name — the
|
|
363
|
+
* loader's class name (or variable name), the part of the qualified name after the package.
|
|
364
|
+
* See `SourceRecordLoaderDeclaration` for the identity paired with the loader itself.
|
|
365
|
+
*/
|
|
366
|
+
export type SourceRecordDeclarationIdentity = {
|
|
367
|
+
source: string;
|
|
368
|
+
qualifiedName: string;
|
|
369
|
+
name: string;
|
|
370
|
+
};
|
|
371
|
+
|
|
345
372
|
export type SourceRecordOptions<T = any> = {
|
|
373
|
+
/**
|
|
374
|
+
* Fields the sync derives from the DECLARATION ITSELF rather than from its record — for
|
|
375
|
+
* tables whose rows carry facts about their declaration: the migration ledger's `name` is
|
|
376
|
+
* the declaring loader's class name, which no migration author should have to type twice.
|
|
377
|
+
* Called once per declaration per boot; the result is merged over the declared record
|
|
378
|
+
* BEFORE the drift comparison, so the derivation owns those fields (a value typed into the
|
|
379
|
+
* record does not survive it), an existing row missing a derived field is backfilled by the
|
|
380
|
+
* next boot exactly once, and an unchanged derivation never rewrites a row.
|
|
381
|
+
*/
|
|
382
|
+
fromDeclaration?: (declaration: SourceRecordDeclarationIdentity) => Partial<T>;
|
|
346
383
|
/**
|
|
347
384
|
* What the source-record sync does with rows it previously loaded from source
|
|
348
385
|
* (`is_loaded_from_source = true`) whose declaration no longer exists:
|
package/src/auth/TableAuth.ts
CHANGED
|
@@ -67,27 +67,19 @@ export class TableAuth {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Non-throwing read of ONE declared identity against the CURRENT user — the same grammar the
|
|
72
|
+
* table doors speak (`'public' | 'authenticated' | roles[] | { permission }`), for declared
|
|
73
|
+
* UI affordances that carry their own grant (a record form's declared panels, `Table.auth.ui`).
|
|
74
|
+
* An UNDECLARED identity is the default-deny made explicit: admin only. Fails closed (false)
|
|
75
|
+
* when no user can be resolved, like `canPerform`.
|
|
76
|
+
*/
|
|
77
|
+
identityAllows(identity: Identity | undefined): boolean {
|
|
78
|
+
try {
|
|
79
|
+
return identity === undefined ? UserAuth.hasRole('admin') : this.allows(identity);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
78
82
|
}
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
tableAuth.all === 'public' ||
|
|
82
|
-
tableAuth[operation] === 'public' ||
|
|
83
|
-
(tableAuth.all === 'authenticated' && UserAuth.isLoggedIn()) ||
|
|
84
|
-
(tableAuth[operation] === 'authenticated' && UserAuth.isLoggedIn()) ||
|
|
85
|
-
(Array.isArray(tableAuth.all) && UserAuth.hasRoles(tableAuth.all, 'at least one')) ||
|
|
86
|
-
(Array.isArray(tableAuth[operation]) && UserAuth.hasRoles(tableAuth[operation] as string[], 'at least one')) ||
|
|
87
|
-
(isPermissionIdentity(tableAuth.all) && UserAuth.hasPermission(tableAuth.all.permission)) ||
|
|
88
|
-
(isPermissionIdentity(tableAuth[operation]) &&
|
|
89
|
-
UserAuth.hasPermission((tableAuth[operation] as PermissionIdentity).permission))
|
|
90
|
-
);
|
|
91
83
|
}
|
|
92
84
|
|
|
93
85
|
canQuery(table: Table<any>, api: 'db' | 'service' = 'db'): void {
|
|
@@ -113,4 +105,37 @@ export class TableAuth {
|
|
|
113
105
|
throw new TableAuthError(`User is not authorized to delete records from table: ${table.name}`);
|
|
114
106
|
}
|
|
115
107
|
}
|
|
108
|
+
|
|
109
|
+
private canAccess(
|
|
110
|
+
table: Table<any>,
|
|
111
|
+
api: 'db' | 'service',
|
|
112
|
+
operation: 'query' | 'insert' | 'update' | 'delete'
|
|
113
|
+
): boolean {
|
|
114
|
+
const tableAuth = table.auth ? table.auth[api] : undefined;
|
|
115
|
+
if (!tableAuth || Object.keys(tableAuth).length == 0) {
|
|
116
|
+
return UserAuth.hasRole('admin');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Within a declared block an undeclared operation stays closed to everyone (the block is
|
|
120
|
+
// the whole declaration) — `allows` reads undefined as false, so the two doors are exactly
|
|
121
|
+
// the ones the table wrote down.
|
|
122
|
+
return this.allows(tableAuth.all) || this.allows(tableAuth[operation]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One identity against the current user; undefined (nothing declared) is not a grant. */
|
|
126
|
+
private allows(identity: Identity | undefined): boolean {
|
|
127
|
+
if (identity === 'public') {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (identity === 'authenticated') {
|
|
131
|
+
return UserAuth.isLoggedIn();
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(identity)) {
|
|
134
|
+
return UserAuth.hasRoles(identity, 'at least one');
|
|
135
|
+
}
|
|
136
|
+
if (isPermissionIdentity(identity)) {
|
|
137
|
+
return UserAuth.hasPermission(identity.permission);
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
116
141
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
2
|
-
import { Columns, Table, getTables } from '../Table';
|
|
2
|
+
import { Columns, SourceRecordDeclarationIdentity, Table, getTables } from '../Table';
|
|
3
3
|
import { Record as DbRecord, withRecordColumns } from '../Record';
|
|
4
4
|
import { BooleanColumn, StringColumn } from '../Columns';
|
|
5
5
|
|
|
@@ -10,15 +10,28 @@ import { BooleanColumn, StringColumn } from '../Columns';
|
|
|
10
10
|
* and prunes only within it, so servers running different builds against one shared database
|
|
11
11
|
* never delete each other's rows.
|
|
12
12
|
*/
|
|
13
|
-
export type SourceRecordLoaderDeclaration<T extends SourceRecord = SourceRecord> = {
|
|
14
|
-
source: string;
|
|
13
|
+
export type SourceRecordLoaderDeclaration<T extends SourceRecord = SourceRecord> = SourceRecordDeclarationIdentity & {
|
|
15
14
|
loader: SourceRecordLoader<T>;
|
|
16
15
|
};
|
|
17
16
|
|
|
18
17
|
export const getSourceRecordLoaders = <T extends SourceRecord = SourceRecord>(): SourceRecordLoaderDeclaration<T>[] =>
|
|
19
18
|
SourceRepository.get()
|
|
20
19
|
.objectsWithNames<SourceRecordLoader<T>>('@proteinjs/db/SourceRecordLoader')
|
|
21
|
-
.map(({ packageName, object }) => ({
|
|
20
|
+
.map(({ packageName, qualifiedName, object }) => ({
|
|
21
|
+
source: packageName,
|
|
22
|
+
qualifiedName,
|
|
23
|
+
name: declarationName(qualifiedName, packageName),
|
|
24
|
+
loader: object,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The declaration's own name from its reflection qualified name (`<package>/<name>`): the
|
|
29
|
+
* loader's class name for a class declaration, the variable name for a variable one.
|
|
30
|
+
*/
|
|
31
|
+
const declarationName = (qualifiedName: string, packageName: string) =>
|
|
32
|
+
qualifiedName.startsWith(`${packageName}/`)
|
|
33
|
+
? qualifiedName.slice(packageName.length + 1)
|
|
34
|
+
: qualifiedName.slice(qualifiedName.lastIndexOf('/') + 1);
|
|
22
35
|
|
|
23
36
|
export function getSourceRecordTables() {
|
|
24
37
|
const tables = getTables();
|
|
@@ -9,6 +9,9 @@ import { RecordSerializer } from '../Record';
|
|
|
9
9
|
type DeclaredRecord = {
|
|
10
10
|
/** The owning package (the declaring loader's package) — the grain the sync prunes within. */
|
|
11
11
|
source: string;
|
|
12
|
+
/** The declaration's reflection qualified name and its own name (the loader's class name). */
|
|
13
|
+
qualifiedName: string;
|
|
14
|
+
name: string;
|
|
12
15
|
record: Omit<SourceRecord, 'created' | 'updated'>;
|
|
13
16
|
};
|
|
14
17
|
|
|
@@ -56,6 +59,10 @@ export class SourceRecordLoader {
|
|
|
56
59
|
* tables it no longer declares anything for: dropping the last declaration is a removal.
|
|
57
60
|
* - Rows with no owner stamp (written before `source_package` existed) are claimed by the
|
|
58
61
|
* declaration that still matches them; the rest are reported as unowned and left alone.
|
|
62
|
+
* - The version stamp records the version that last WROTE the row (its insert or its last
|
|
63
|
+
* redefinition). A newer version whose declaration is unchanged writes nothing — it neither
|
|
64
|
+
* re-stamps the version nor bumps `updated` — so a release that touches no declaration
|
|
65
|
+
* leaves every row byte-identical (see {@link hasChanges}).
|
|
59
66
|
*
|
|
60
67
|
* Accepted residuals: a package removed from every build leaves its rows behind (no surviving
|
|
61
68
|
* boot carries its authority — clean up explicitly); two builds of one package at the SAME
|
|
@@ -94,9 +101,16 @@ export class SourceRecordLoader {
|
|
|
94
101
|
let unchangedCount = 0;
|
|
95
102
|
let adoptedCount = 0;
|
|
96
103
|
let skippedNewer = removed.skippedNewer;
|
|
97
|
-
for (const { source, record } of records) {
|
|
104
|
+
for (const { source, qualifiedName, name, record } of records) {
|
|
98
105
|
let sourceRecord = record;
|
|
99
106
|
sourceRecord.isLoadedFromSource = true;
|
|
107
|
+
// Fields derived from the declaration itself (e.g. the migration ledger's `name` = the
|
|
108
|
+
// loader's class name) — merged before the drift comparison, so they backfill existing
|
|
109
|
+
// rows exactly once and the derivation, not the declared record, owns them.
|
|
110
|
+
const derived = table.sourceRecordOptions.fromDeclaration?.({ source, qualifiedName, name });
|
|
111
|
+
if (derived) {
|
|
112
|
+
Object.assign(sourceRecord, derived);
|
|
113
|
+
}
|
|
100
114
|
// Ownership stamp: the declaring package (and its version) claims the row. Stamped
|
|
101
115
|
// before the drift comparison so pre-existing rows (including pre-source_package legacy
|
|
102
116
|
// rows and rows whose declaration moved packages) converge to the current owner on
|
|
@@ -408,16 +422,29 @@ export class SourceRecordLoader {
|
|
|
408
422
|
* Object-valued fields (e.g. `JsonColumn` blobs) are treated as source-authoritative:
|
|
409
423
|
* any structural drift, including extra keys left behind by earlier source versions,
|
|
410
424
|
* triggers a rewrite. Primitive columns retain their existing semantics.
|
|
425
|
+
*
|
|
426
|
+
* The version stamp (`source_package_version`) is bookkeeping, not definition: it names the
|
|
427
|
+
* version that last WROTE the row and rides every source record (every release bumps the
|
|
428
|
+
* declaring package), so comparing it would rewrite every row on every release — the churn
|
|
429
|
+
* that bumped `updated` on migration ledger rows nobody had touched. A stamped row's version
|
|
430
|
+
* is therefore never drift by itself; it moves only when a genuine change rewrites the row.
|
|
431
|
+
* A row with NO stamp (written before versions existed, or by a build that could not resolve
|
|
432
|
+
* its version) still gets one — once — so version ordering can protect it from then on.
|
|
411
433
|
*/
|
|
412
434
|
private async hasChanges(table: Table<any>, sourceRecord: any, existingRecord: any): Promise<boolean> {
|
|
413
435
|
const serializer = new RecordSerializer(table);
|
|
414
436
|
const serializedSource = await serializer.serialize(sourceRecord);
|
|
415
437
|
const serializedExisting = await serializer.serialize(existingRecord);
|
|
438
|
+
const versionStampColumn: string | undefined = (table.columns as any).sourcePackageVersion?.name;
|
|
416
439
|
for (const columnName in serializedSource) {
|
|
417
440
|
if (columnName === 'id' || columnName === 'created' || columnName === 'updated') {
|
|
418
441
|
continue;
|
|
419
442
|
}
|
|
420
443
|
|
|
444
|
+
if (columnName === versionStampColumn && serializedExisting[columnName] != null) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
421
448
|
const sourceValue = serializedSource[columnName];
|
|
422
449
|
const existingValue = serializedExisting[columnName];
|
|
423
450
|
if (this.findMismatchPath(sourceValue, existingValue, columnName)) {
|
|
@@ -439,13 +466,13 @@ export class SourceRecordLoader {
|
|
|
439
466
|
}
|
|
440
467
|
|
|
441
468
|
const buildSources = new Set<string>();
|
|
442
|
-
for (const { source, loader } of getSourceRecordLoaders()) {
|
|
469
|
+
for (const { source, qualifiedName, name, loader } of getSourceRecordLoaders()) {
|
|
443
470
|
buildSources.add(source);
|
|
444
471
|
if (!tables[loader.table.name]) {
|
|
445
472
|
tables[loader.table.name] = { table: loader.table, records: [] };
|
|
446
473
|
}
|
|
447
474
|
|
|
448
|
-
tables[loader.table.name].records.push({ source, record: loader.record });
|
|
475
|
+
tables[loader.table.name].records.push({ source, qualifiedName, name, record: loader.record });
|
|
449
476
|
}
|
|
450
477
|
|
|
451
478
|
return { tables, buildSources };
|
|
@@ -4,6 +4,14 @@ import { Table } from '../Table';
|
|
|
4
4
|
import { SourceRecord, withSourceRecordColumns } from '../source/SourceRecord';
|
|
5
5
|
|
|
6
6
|
export interface Migration extends SourceRecord {
|
|
7
|
+
/**
|
|
8
|
+
* The declaring loader's class name (`BackfillOnboardingStateForExistingAccounts`) — the
|
|
9
|
+
* ledger's human handle, leading the Migrations table. Derived by the source-record sync from
|
|
10
|
+
* the declaration itself ({@link MigrationTable.sourceRecordOptions} `fromDeclaration`), never
|
|
11
|
+
* typed into a migration's record: existing rows are backfilled by the first boot after this
|
|
12
|
+
* column ships and left alone from then on (a derived field is drift-compared like any other).
|
|
13
|
+
*/
|
|
14
|
+
name?: string;
|
|
7
15
|
description: string;
|
|
8
16
|
/**
|
|
9
17
|
* The explicit non-automatable class (plans/POST_RELEASE_QUEUE.md 27f): destructive
|
|
@@ -71,32 +79,51 @@ export class MigrationTable extends Table<Migration> {
|
|
|
71
79
|
service: { query: { permission: 'dev' }, update: { permission: 'dev' }, delete: { permission: 'dev' } },
|
|
72
80
|
};
|
|
73
81
|
/**
|
|
74
|
-
* The row scan the founder actually reads
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* record form.
|
|
82
|
+
* The row scan the founder actually reads (ruled 2026-09-02): WHICH migration (name — the
|
|
83
|
+
* loader's class name), what it does, how the run went, WHEN it ran ("Ran at" = start_time,
|
|
84
|
+
* the one timestamp that answers "when did it land"), how long, when it finished, and a
|
|
85
|
+
* glimpse of its output (the mono JSON snippet; the record form carries the full value).
|
|
86
|
+
* The declaration flags and the failure detail stay on the record form. Most recent run
|
|
87
|
+
* first; never-run rows last — GoogleSQL orders NULL as the least value, so start_time DESC
|
|
88
|
+
* places rows without a run after every real timestamp; ledger order (created) breaks ties.
|
|
78
89
|
*/
|
|
79
90
|
public ui: Table<Migration>['ui'] = {
|
|
80
91
|
recordTable: {
|
|
81
|
-
columns: ['description', 'status', '
|
|
92
|
+
columns: ['name', 'description', 'status', 'startTime', 'duration', 'endTime', 'output'],
|
|
93
|
+
sort: [
|
|
94
|
+
{ field: 'startTime', desc: true },
|
|
95
|
+
{ field: 'created', desc: false },
|
|
96
|
+
],
|
|
82
97
|
},
|
|
83
98
|
};
|
|
99
|
+
/**
|
|
100
|
+
* Declared in the order the record form reads them (within a section the form keeps schema
|
|
101
|
+
* order): the migration's identity and flags up top (unlabeled), then its Run — status, ran
|
|
102
|
+
* at, duration, end time, the full output, failure message and stack — then the System meta.
|
|
103
|
+
* Column order carries no DDL weight on an existing table.
|
|
104
|
+
*/
|
|
84
105
|
public columns = withSourceRecordColumns<Migration>({
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
endTime: new DateTimeColumn('end_time'),
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
name: new StringColumn('name'),
|
|
107
|
+
description: new StringColumn('description', { ui: { formGroup: 'identity' } }, 4000),
|
|
108
|
+
manual: new BooleanColumn('manual', { ui: { formGroup: 'identity' } }),
|
|
109
|
+
preSchemaSync: new BooleanColumn('pre_schema_sync', { ui: { formGroup: 'identity' } }),
|
|
110
|
+
retired: new BooleanColumn('retired', { ui: { formGroup: 'identity' } }),
|
|
111
|
+
status: new StringColumn('status', { defaultValue: async () => 'proposed', ui: { formGroup: 'run' } }),
|
|
112
|
+
startTime: new DateTimeColumn('start_time', { ui: { label: 'Ran at', formGroup: 'run' } }),
|
|
113
|
+
duration: new StringColumn('duration', { ui: { formGroup: 'run' } }),
|
|
114
|
+
endTime: new DateTimeColumn('end_time', { ui: { formGroup: 'run' } }),
|
|
115
|
+
// Opted visible: an ObjectColumn hides by default, but a run's output is exactly what the
|
|
116
|
+
// ledger is opened to read (snippet on the row, full JSON on the form).
|
|
117
|
+
output: new ObjectColumn('output', { ui: { hidden: false, formGroup: 'run' } }),
|
|
118
|
+
failureMessage: new StringColumn('failure_message', { ui: { formGroup: 'run' } }, 4000),
|
|
119
|
+
failureStack: new StringColumn('failure_stack', { ui: { formGroup: 'run' } }, 'MAX'),
|
|
96
120
|
});
|
|
97
121
|
public sourceRecordOptions: Table<Migration>['sourceRecordOptions'] = {
|
|
98
122
|
// The ledger outlives the migration class: run history stays when the source is deleted.
|
|
99
123
|
onSourceRemoved: 'keep',
|
|
124
|
+
// The ledger's `name` is the declaring loader's class name — derived from the declaration
|
|
125
|
+
// by the sync (backfilled once onto existing rows), never typed into each migration.
|
|
126
|
+
fromDeclaration: ({ name }) => ({ name }),
|
|
100
127
|
ui: {
|
|
101
128
|
hideColumns: true,
|
|
102
129
|
},
|
|
@@ -152,3 +152,93 @@ describe('SourceRecordLoader.findMismatchPath', () => {
|
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Stamp hygiene on the real migration-ledger shape: `hasChanges` decides whether a boot
|
|
158
|
+
* rewrites a row (bumping `updated`). The ownership stamps ride the declared record, so the
|
|
159
|
+
* comparison must know which of them mean "the definition changed" and which are bookkeeping.
|
|
160
|
+
*/
|
|
161
|
+
describe('SourceRecordLoader.hasChanges — stamp hygiene on the migration ledger', () => {
|
|
162
|
+
type HasChangesInternals = { hasChanges: (table: any, source: any, existing: any) => Promise<boolean> };
|
|
163
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
164
|
+
const { MigrationTable } = require('../src/tables/MigrationTable');
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
166
|
+
const moment = require('moment');
|
|
167
|
+
const loader = () => new SourceRecordLoader() as unknown as HasChangesInternals;
|
|
168
|
+
const table = new MigrationTable();
|
|
169
|
+
|
|
170
|
+
/** What a build declares for a migration (the run function rides along, as it does at boot). */
|
|
171
|
+
const declared = (overrides: Record<string, unknown> = {}) => ({
|
|
172
|
+
id: 'bee7a15c-369f-4e77-abf7-ccd5dc3ae60c',
|
|
173
|
+
description: 'Marks every existing account onboarded',
|
|
174
|
+
run: async () => undefined,
|
|
175
|
+
isLoadedFromSource: true,
|
|
176
|
+
sourcePackage: '@n3xa/app-server',
|
|
177
|
+
sourcePackageVersion: '1.24.0',
|
|
178
|
+
...overrides,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** The ledger row a previous release wrote and later ran. */
|
|
182
|
+
const stored = (overrides: Record<string, unknown> = {}) => ({
|
|
183
|
+
id: 'bee7a15c-369f-4e77-abf7-ccd5dc3ae60c',
|
|
184
|
+
description: 'Marks every existing account onboarded',
|
|
185
|
+
isLoadedFromSource: true,
|
|
186
|
+
sourcePackage: '@n3xa/app-server',
|
|
187
|
+
sourcePackageVersion: '1.23.0',
|
|
188
|
+
status: 'success',
|
|
189
|
+
startTime: moment('2026-08-30T10:00:00Z'),
|
|
190
|
+
endTime: moment('2026-08-30T10:00:02Z'),
|
|
191
|
+
duration: '2 secs',
|
|
192
|
+
output: { rowsInserted: 12 },
|
|
193
|
+
created: moment('2026-08-30T09:00:00Z'),
|
|
194
|
+
updated: moment('2026-08-30T10:00:02Z'),
|
|
195
|
+
...overrides,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('a newer package version alone is NOT a change — the every-release re-stamp', async () => {
|
|
199
|
+
expect(await loader().hasChanges(table, declared(), stored())).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('a row with no version stamp gets one (once)', async () => {
|
|
203
|
+
const unstamped = stored();
|
|
204
|
+
delete (unstamped as Partial<typeof unstamped>).sourcePackageVersion;
|
|
205
|
+
expect(await loader().hasChanges(table, declared(), unstamped)).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('a redefined description is still a change', async () => {
|
|
209
|
+
expect(await loader().hasChanges(table, declared({ description: 'Reworded' }), stored())).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('ledger-owned run state (status, times, output) never reads as drift', async () => {
|
|
213
|
+
expect(await loader().hasChanges(table, declared({ sourcePackageVersion: '1.23.0' }), stored())).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('getSourceRecordLoaders — declaration identity', () => {
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
219
|
+
const { SourceRepository } = require('@proteinjs/reflection');
|
|
220
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
221
|
+
const { getSourceRecordLoaders } = require('../src/source/SourceRecord');
|
|
222
|
+
const namedObjectCache = () => (SourceRepository.get() as any).namedObjectCache;
|
|
223
|
+
|
|
224
|
+
afterEach(() => {
|
|
225
|
+
delete namedObjectCache()['@proteinjs/db/SourceRecordLoader'];
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("carries the declaration's own name (the loader class) beside its package and qualified name", () => {
|
|
229
|
+
namedObjectCache()['@proteinjs/db/SourceRecordLoader'] = [
|
|
230
|
+
{
|
|
231
|
+
qualifiedName: '@n3xa/app-server/BackfillOnboardingStateForExistingAccounts',
|
|
232
|
+
packageName: '@n3xa/app-server',
|
|
233
|
+
object: { table: {}, record: {} },
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
const [declaration] = getSourceRecordLoaders() as any[];
|
|
238
|
+
expect(declaration).toMatchObject({
|
|
239
|
+
source: '@n3xa/app-server',
|
|
240
|
+
qualifiedName: '@n3xa/app-server/BackfillOnboardingStateForExistingAccounts',
|
|
241
|
+
name: 'BackfillOnboardingStateForExistingAccounts',
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { UserAuth } from '@proteinjs/user-auth';
|
|
2
|
+
import { Table } from '../src/Table';
|
|
3
|
+
import { withRecordColumns, Record } from '../src/Record';
|
|
4
|
+
import { StringColumn } from '../src/Columns';
|
|
5
|
+
import { TableAuth } from '../src/auth/TableAuth';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `TableAuth.identityAllows` — the one identity grammar (`'public' | 'authenticated' | roles[] |
|
|
9
|
+
* { permission }`) read for a DECLARED UI affordance that carries its own grant (a record form's
|
|
10
|
+
* declared panels, `Table.auth.ui`). Outcomes pinned:
|
|
11
|
+
* - each identity form resolves against the current user; 'admin' is break-glass throughout;
|
|
12
|
+
* - an UNDECLARED identity is admin-only (default-deny made explicit), never open;
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* `UserAuth` reads from a static repo; tests stub it directly per identity — no server needed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface Doc extends Record {
|
|
21
|
+
title: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class QueryOnlyTable extends Table<Doc> {
|
|
25
|
+
public name = 'identity_query_only_test';
|
|
26
|
+
public auth: Table<Doc>['auth'] = {
|
|
27
|
+
db: { query: 'authenticated' },
|
|
28
|
+
};
|
|
29
|
+
public columns = withRecordColumns<Doc>({ title: new StringColumn('title') });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const setUser = (roles: string[], email = 'user@test.local') => {
|
|
33
|
+
(UserAuth as any).userRepo = { getUser: () => ({ email, roles }) };
|
|
34
|
+
};
|
|
35
|
+
const setGuest = () => {
|
|
36
|
+
(UserAuth as any).userRepo = { getUser: () => ({ email: 'guest', roles: [] }) };
|
|
37
|
+
};
|
|
38
|
+
const setMapping = (mapping: { [permission: string]: string[] }) => {
|
|
39
|
+
(UserAuth as any).permissionRolesMapping = { getRoles: (permission: string) => mapping[permission] };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('TableAuth.identityAllows', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
setMapping({ usage: ['usage'] });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
(UserAuth as any).userRepo = undefined;
|
|
49
|
+
(UserAuth as any).permissionRolesMapping = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("'public' admits anyone, including a guest", () => {
|
|
53
|
+
setGuest();
|
|
54
|
+
expect(new TableAuth().identityAllows('public')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("'authenticated' admits a signed-in user and refuses a guest", () => {
|
|
58
|
+
setUser([]);
|
|
59
|
+
expect(new TableAuth().identityAllows('authenticated')).toBe(true);
|
|
60
|
+
setGuest();
|
|
61
|
+
expect(new TableAuth().identityAllows('authenticated')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a roles list admits a holder of at least one role', () => {
|
|
65
|
+
setUser(['ops']);
|
|
66
|
+
expect(new TableAuth().identityAllows(['users', 'ops'])).toBe(true);
|
|
67
|
+
setUser(['dev']);
|
|
68
|
+
expect(new TableAuth().identityAllows(['users', 'ops'])).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a permission identity resolves through the consumer mapping; admin is break-glass', () => {
|
|
72
|
+
setUser(['usage']);
|
|
73
|
+
expect(new TableAuth().identityAllows({ permission: 'usage' })).toBe(true);
|
|
74
|
+
setUser(['users']);
|
|
75
|
+
expect(new TableAuth().identityAllows({ permission: 'usage' })).toBe(false);
|
|
76
|
+
setUser(['admin']);
|
|
77
|
+
expect(new TableAuth().identityAllows({ permission: 'usage' })).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an undeclared identity is admin-only, never open', () => {
|
|
81
|
+
setUser(['usage', 'users', 'ops']);
|
|
82
|
+
expect(new TableAuth().identityAllows(undefined)).toBe(false);
|
|
83
|
+
setUser(['admin']);
|
|
84
|
+
expect(new TableAuth().identityAllows(undefined)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('fails closed when no user can be resolved', () => {
|
|
88
|
+
(UserAuth as any).userRepo = {
|
|
89
|
+
getUser: () => {
|
|
90
|
+
throw new Error('no session');
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
expect(new TableAuth().identityAllows('authenticated')).toBe(false);
|
|
94
|
+
expect(new TableAuth().identityAllows({ permission: 'usage' })).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('TableAuth operation doors keep their declared-block semantics', () => {
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
(UserAuth as any).userRepo = undefined;
|
|
101
|
+
(UserAuth as any).permissionRolesMapping = undefined;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a declared operation opens to its identity', () => {
|
|
105
|
+
setUser([]);
|
|
106
|
+
expect(new TableAuth().canPerform(new QueryOnlyTable(), 'query')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('within a declared block an undeclared operation stays closed — admin included', () => {
|
|
110
|
+
setUser(['admin']);
|
|
111
|
+
expect(new TableAuth().canPerform(new QueryOnlyTable(), 'insert')).toBe(false);
|
|
112
|
+
setUser([]);
|
|
113
|
+
expect(new TableAuth().canPerform(new QueryOnlyTable(), 'insert')).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|