@snolla/data 0.0.0-stage → 1.0.7
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/dist/actor.d.ts +28 -0
- package/dist/actor.js +63 -0
- package/dist/audit.d.ts +36 -0
- package/dist/audit.js +198 -0
- package/dist/dataProvider.d.ts +41 -0
- package/dist/dataProvider.js +148 -0
- package/dist/dataSource.d.ts +4 -0
- package/dist/dataSource.js +65 -0
- package/dist/dbProvider.d.ts +1261 -0
- package/dist/dbProvider.js +1898 -0
- package/dist/freeze.d.ts +29 -0
- package/dist/freeze.js +69 -0
- package/dist/guard.d.ts +53 -0
- package/dist/guard.js +397 -0
- package/dist/insert.d.ts +9 -0
- package/dist/insert.js +44 -0
- package/dist/migrations/siteid.d.mts +738 -0
- package/dist/migrations/siteid.mjs +512 -0
- package/dist/publish.d.ts +1 -0
- package/dist/publish.js +1 -0
- package/dist/schema/dsl.d.ts +64 -0
- package/dist/schema/dsl.js +28 -0
- package/dist/schema/generate.d.mts +14 -0
- package/dist/schema/generate.mjs +282 -0
- package/dist/schema/generated/ddl.d.ts +7 -0
- package/dist/schema/generated/ddl.js +11 -0
- package/dist/schema/generated/kysely-types.d.ts +1988 -0
- package/dist/schema/generated/kysely-types.js +5 -0
- package/dist/schema/index.d.ts +2 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/provision.d.mts +11 -0
- package/dist/schema/provision.mjs +111 -0
- package/dist/schema/tables.d.ts +2 -0
- package/dist/schema/tables.js +3301 -0
- package/dist/scripts/canon-audit.d.mts +25 -0
- package/dist/scripts/canon-audit.mjs +213 -0
- package/dist/scripts/canon-smoke.d.mts +1 -0
- package/dist/scripts/canon-smoke.mjs +44 -0
- package/dist/scripts/dialect-ddl-stand.d.mts +1 -0
- package/dist/scripts/dialect-ddl-stand.mjs +136 -0
- package/dist/scripts/owner-spec-check.d.mts +1 -0
- package/dist/scripts/owner-spec-check.mjs +166 -0
- package/dist/scripts/owners/owner-resolve-spec.json +540 -0
- package/dist/scripts/owners-audit.d.mts +60 -0
- package/dist/scripts/owners-audit.mjs +443 -0
- package/dist/scripts/owners-fixture.d.mts +20 -0
- package/dist/scripts/owners-fixture.mjs +192 -0
- package/dist/scripts/schema-lengths-audit.d.mts +22 -0
- package/dist/scripts/schema-lengths-audit.mjs +146 -0
- package/dist/scripts/siteid-migrate.d.mts +7 -0
- package/dist/scripts/siteid-migrate.mjs +260 -0
- package/dist/scripts/tenant-invariant-audit.d.mts +1 -0
- package/dist/scripts/tenant-invariant-audit.mjs +34 -0
- package/dist/singleMode.d.ts +5 -0
- package/dist/singleMode.js +23 -0
- package/dist/sqliteDialect.d.ts +6 -0
- package/dist/sqliteDialect.js +92 -0
- package/dist/templateDefs.d.ts +38 -0
- package/dist/templateDefs.js +163 -0
- package/dist/test/fixtures/broken-driver/index.d.ts +3 -0
- package/dist/test/fixtures/broken-driver/index.js +5 -0
- package/dist/test/support/dialect-stand.d.ts +37 -0
- package/dist/test/support/dialect-stand.js +162 -0
- package/dist/test/support/render-parity.d.ts +44 -0
- package/dist/test/support/render-parity.js +262 -0
- package/package.json +50 -5
- package/README.md +0 -3
package/dist/actor.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Kysely } from 'kysely';
|
|
2
|
+
export declare const UNKNOWN_ACTOR = "\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0430\u043A\u0442\u043E\u0440";
|
|
3
|
+
export declare const UNAVAILABLE_ADMIN = "\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D";
|
|
4
|
+
/** The administrator columns a name can come from: full name, then username, then email. */
|
|
5
|
+
export interface AdministratorNameRow {
|
|
6
|
+
AdministratorId?: string | null;
|
|
7
|
+
FirstName?: string | null;
|
|
8
|
+
LastName?: string | null;
|
|
9
|
+
Username?: string | null;
|
|
10
|
+
Email?: string | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Label for an administrator row, or `пользователь недоступен` when the row is gone.
|
|
14
|
+
* Takes the row rather than an id so a caller that already joined `Administrators` does
|
|
15
|
+
* not pay for a second read.
|
|
16
|
+
*/
|
|
17
|
+
export declare const administratorLabel: (row: AdministratorNameRow | null | undefined) => string;
|
|
18
|
+
export interface ResolvedActor {
|
|
19
|
+
/** The administrator behind the actor id, when the id resolves to one. */
|
|
20
|
+
administratorId: string | null;
|
|
21
|
+
label: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve an actor id to the administrator behind it and the label to show.
|
|
25
|
+
* An id that is neither an administrator nor an API key resolves to nobody — and the
|
|
26
|
+
* label is the "unknown actor" wording, never the raw GUID.
|
|
27
|
+
*/
|
|
28
|
+
export declare const resolveActor: (db: Kysely<unknown>, actorId: string | null | undefined) => Promise<ResolvedActor>;
|
package/dist/actor.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The actor label — one resolver for every surface (requirements:67 R3.5/R3.6, task:3982).
|
|
2
|
+
//
|
|
3
|
+
// A row carries an administrator id (`CreatedById`/`UpdatedById`), and the id is a soft
|
|
4
|
+
// reference: an import into a database without that administrator keeps it, so the row
|
|
5
|
+
// still renders history. Two labels cover the two ways that can end:
|
|
6
|
+
//
|
|
7
|
+
// * the id resolves to nobody — not an administrator, not an API key:
|
|
8
|
+
// `неизвестный актор` (there is no person to name);
|
|
9
|
+
// * the id is an administrator's, but the row is gone from this database:
|
|
10
|
+
// `пользователь недоступен` (we know who it was, we cannot name them).
|
|
11
|
+
//
|
|
12
|
+
// The chain is the contract's: `Administrators` → `ApiKeys` (a key acts on behalf of its
|
|
13
|
+
// owner) → unknown. Kept here rather than in the admin API so the engine, the cell and a
|
|
14
|
+
// future UI all resolve the same way, and so a surface cannot invent a third wording.
|
|
15
|
+
import { sql } from 'kysely';
|
|
16
|
+
export const UNKNOWN_ACTOR = 'неизвестный актор';
|
|
17
|
+
export const UNAVAILABLE_ADMIN = 'пользователь недоступен';
|
|
18
|
+
/**
|
|
19
|
+
* Label for an administrator row, or `пользователь недоступен` when the row is gone.
|
|
20
|
+
* Takes the row rather than an id so a caller that already joined `Administrators` does
|
|
21
|
+
* not pay for a second read.
|
|
22
|
+
*/
|
|
23
|
+
export const administratorLabel = (row) => {
|
|
24
|
+
// The row being absent is a different story from the row having no name: an
|
|
25
|
+
// administrator with no FirstName/Username/Email still exists and can log in, so
|
|
26
|
+
// calling them "пользователь недоступен" would be a lie about the data.
|
|
27
|
+
if (!row)
|
|
28
|
+
return UNAVAILABLE_ADMIN;
|
|
29
|
+
const full = [row.FirstName, row.LastName].map((part) => part?.trim()).filter(Boolean).join(' ');
|
|
30
|
+
return full || row.Username?.trim() || row.Email?.trim() || UNKNOWN_ACTOR;
|
|
31
|
+
};
|
|
32
|
+
/** Read an administrator by id, or by the key id that acts on their behalf. */
|
|
33
|
+
const readAdministrator = async (db, actorId) => {
|
|
34
|
+
const direct = await sql `
|
|
35
|
+
select ${sql.id('AdministratorId')}, ${sql.id('FirstName')}, ${sql.id('LastName')}, ${sql.id('Username')}, ${sql.id('Email')}
|
|
36
|
+
from ${sql.table('Administrators')}
|
|
37
|
+
where ${sql.id('AdministratorId')} = ${actorId}
|
|
38
|
+
`.execute(db);
|
|
39
|
+
if (direct.rows[0])
|
|
40
|
+
return direct.rows[0];
|
|
41
|
+
const viaKey = await sql `
|
|
42
|
+
select a.${sql.id('AdministratorId')} as ${sql.id('AdministratorId')}, a.${sql.id('FirstName')} as ${sql.id('FirstName')},
|
|
43
|
+
a.${sql.id('LastName')} as ${sql.id('LastName')}, a.${sql.id('Username')} as ${sql.id('Username')},
|
|
44
|
+
a.${sql.id('Email')} as ${sql.id('Email')}
|
|
45
|
+
from ${sql.table('ApiKeys')} k
|
|
46
|
+
join ${sql.table('Administrators')} a on a.${sql.id('AdministratorId')} = k.${sql.id('AdministratorId')}
|
|
47
|
+
where k.${sql.id('ApiKeyId')} = ${actorId}
|
|
48
|
+
`.execute(db);
|
|
49
|
+
return viaKey.rows[0] ?? null;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Resolve an actor id to the administrator behind it and the label to show.
|
|
53
|
+
* An id that is neither an administrator nor an API key resolves to nobody — and the
|
|
54
|
+
* label is the "unknown actor" wording, never the raw GUID.
|
|
55
|
+
*/
|
|
56
|
+
export const resolveActor = async (db, actorId) => {
|
|
57
|
+
if (!actorId)
|
|
58
|
+
return { administratorId: null, label: UNKNOWN_ACTOR };
|
|
59
|
+
const administrator = await readAdministrator(db, actorId);
|
|
60
|
+
if (!administrator)
|
|
61
|
+
return { administratorId: null, label: UNKNOWN_ACTOR };
|
|
62
|
+
return { administratorId: administrator.AdministratorId, label: administratorLabel(administrator) };
|
|
63
|
+
};
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Kysely } from 'kysely';
|
|
2
|
+
export type AuditCheck = 'missing-table' | 'unexpected-table' | 'missing-tenant-key' | 'unexpected-tenant-key' | 'tenant-key-nullable' | 'divergent-tenant-key' | 'divergent-tenant-key-vs-owner' | 'ambiguous-owner' | 'dangling-owner';
|
|
3
|
+
export interface AuditViolation {
|
|
4
|
+
check: AuditCheck;
|
|
5
|
+
table?: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
/** how many rows are affected, when the check counts rows. */
|
|
8
|
+
rows?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A polymorphic-owner finding. `divergent` and an inexcusable `dangling` are also
|
|
12
|
+
* violations; `ambiguous` is a report, because picking by declaration order is how the
|
|
13
|
+
* contract resolves and only the data can say whether the pick was right.
|
|
14
|
+
*/
|
|
15
|
+
export interface OwnerResolveFinding {
|
|
16
|
+
check: 'divergent-tenant-key-vs-owner' | 'ambiguous-owner' | 'dangling-owner';
|
|
17
|
+
table: string;
|
|
18
|
+
detail: string;
|
|
19
|
+
rows: number;
|
|
20
|
+
/** `dangling-owner` only: the contract declares this orphan shape legitimate. */
|
|
21
|
+
allowed?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface OwnerResolveReport {
|
|
24
|
+
divergent: number;
|
|
25
|
+
ambiguous: number;
|
|
26
|
+
dangling: number;
|
|
27
|
+
danglingAllowed: number;
|
|
28
|
+
findings: OwnerResolveFinding[];
|
|
29
|
+
}
|
|
30
|
+
export interface AuditReport {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
checks: number;
|
|
33
|
+
violations: AuditViolation[];
|
|
34
|
+
ownerResolve: OwnerResolveReport;
|
|
35
|
+
}
|
|
36
|
+
export declare const auditTenantInvariants: (db: Kysely<unknown>) => Promise<AuditReport>;
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Tenant-invariant audit (task:3814; plan:48 R1-Q2/R1-Q5).
|
|
2
|
+
//
|
|
3
|
+
// A reusable check that a database actually *is* the canon — and stays one after
|
|
4
|
+
// an import. It answers the three questions the acceptance criteria ask:
|
|
5
|
+
//
|
|
6
|
+
// 1. schema: every canon table is present, every site-scoped table carries a
|
|
7
|
+
// NOT NULL `SiteId`, no table carries one the canon does not declare, and
|
|
8
|
+
// nothing outside the canon has appeared;
|
|
9
|
+
// 2. data: no row disagrees with its parent about the tenant across a foreign
|
|
10
|
+
// key chain (`child.SiteId = parent.SiteId`);
|
|
11
|
+
// 3. (removed by task:4052) a quarantine sentinel row used to be required and
|
|
12
|
+
// checked for its freeze. A single-tenant database has no orphan rows to park,
|
|
13
|
+
// so the canon carries no such row and there is nothing to verify.
|
|
14
|
+
//
|
|
15
|
+
// Everything is expressed through Kysely, so the same audit runs on Postgres,
|
|
16
|
+
// MariaDB and SQLite — the dialects the product must support (plan:49 Q6).
|
|
17
|
+
//
|
|
18
|
+
// The audit is intentionally independent of the guard: the guard prevents bad
|
|
19
|
+
// writes, the audit proves nothing bad is there. Both read the same canon.
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { sql } from 'kysely';
|
|
22
|
+
import { POLY_ROOTS, polyLinks } from './migrations/siteid.mjs';
|
|
23
|
+
import { canonicalSchema, isSiteScoped } from "./schema/index.js";
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
// The owner-resolution contract, the same one the SiteId migration writes rows through.
|
|
26
|
+
// The audit reads that list rather than a copy of the rules: a check derived from a second
|
|
27
|
+
// copy proves the wrong thing (the defect task:3894 removed from the planner).
|
|
28
|
+
const ownerSpec = require('./scripts/owners/owner-resolve-spec.json');
|
|
29
|
+
const CANON_TABLES = new Map(canonicalSchema.tables.map((t) => [t.name, t]));
|
|
30
|
+
const readRows = (result) => result.rows ?? [];
|
|
31
|
+
const readCount = (result) => {
|
|
32
|
+
const [first = {}] = readRows(result);
|
|
33
|
+
const value = first.c ?? Object.values(first)[0];
|
|
34
|
+
return Number(value ?? 0);
|
|
35
|
+
};
|
|
36
|
+
/** MariaDB and SQLite materialise `bool` as 0/1; Postgres as a real boolean. */
|
|
37
|
+
const isTruthy = (value) => value === true || value === 1 || value === '1';
|
|
38
|
+
export const auditTenantInvariants = async (db) => {
|
|
39
|
+
const violations = [];
|
|
40
|
+
// `checks` counts verification steps that actually ran. It is reported so a
|
|
41
|
+
// caller can tell "nothing was wrong" from "nothing was looked at"; a
|
|
42
|
+
// formula over the canon would lie exactly when the database drifts.
|
|
43
|
+
let checks = 0;
|
|
44
|
+
const liveTables = await db.introspection.getTables();
|
|
45
|
+
const live = new Map(liveTables.map((t) => [t.name, t]));
|
|
46
|
+
// 1. schema shape
|
|
47
|
+
for (const table of canonicalSchema.tables) {
|
|
48
|
+
checks += 1; // the canon table is present under its own name
|
|
49
|
+
const liveTable = live.get(table.name);
|
|
50
|
+
if (!liveTable) {
|
|
51
|
+
violations.push({ check: 'missing-table', table: table.name, detail: 'canon table is absent from the database' });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
checks += 1; // the tenant-key column agrees with the canon (present or not)
|
|
55
|
+
const expected = table.columns.SiteId !== undefined;
|
|
56
|
+
const actual = liveTable.columns.find((c) => c.name === 'SiteId');
|
|
57
|
+
if (expected && !actual) {
|
|
58
|
+
violations.push({ check: 'missing-tenant-key', table: table.name, detail: 'SiteId column is missing' });
|
|
59
|
+
}
|
|
60
|
+
if (!expected && actual) {
|
|
61
|
+
violations.push({
|
|
62
|
+
check: 'unexpected-tenant-key',
|
|
63
|
+
table: table.name,
|
|
64
|
+
detail: 'SiteId column exists but the canon does not declare it',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (expected && actual && table.columns.SiteId.nullable !== true) {
|
|
68
|
+
checks += 1; // the tenant key is NOT NULL, as the canon says
|
|
69
|
+
if (actual.isNullable) {
|
|
70
|
+
violations.push({ check: 'tenant-key-nullable', table: table.name, detail: 'SiteId is nullable, the canon says NOT NULL' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const liveTable of liveTables) {
|
|
75
|
+
checks += 1; // every live table is compared against the canon
|
|
76
|
+
if (!CANON_TABLES.has(liveTable.name)) {
|
|
77
|
+
violations.push({ check: 'unexpected-table', table: liveTable.name, detail: 'table outside the canon' });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 2. data: the tenant key must agree along every foreign key chain that
|
|
81
|
+
// stays inside site-scoped tables
|
|
82
|
+
for (const child of canonicalSchema.tables.filter(isSiteScoped)) {
|
|
83
|
+
for (const fk of child.fks ?? []) {
|
|
84
|
+
const parent = CANON_TABLES.get(fk.ref);
|
|
85
|
+
if (!parent || !isSiteScoped(parent))
|
|
86
|
+
continue;
|
|
87
|
+
if (!live.has(child.name) || !live.has(parent.name))
|
|
88
|
+
continue;
|
|
89
|
+
const result = await sql `
|
|
90
|
+
select count(*) as c
|
|
91
|
+
from ${sql.table(child.name)} c
|
|
92
|
+
join ${sql.table(parent.name)} p
|
|
93
|
+
on ${sql.join(fk.columns.map((column, i) => sql `${sql.id('c', column)} = ${sql.id('p', fk.refColumns[i])}`), sql ` and `)}
|
|
94
|
+
where ${sql.id('c', 'SiteId')} <> ${sql.id('p', 'SiteId')}
|
|
95
|
+
`.execute(db);
|
|
96
|
+
checks += 1; // one query per FK chain that stays inside the canon
|
|
97
|
+
const rows = readCount(result);
|
|
98
|
+
if (rows > 0) {
|
|
99
|
+
violations.push({
|
|
100
|
+
check: 'divergent-tenant-key',
|
|
101
|
+
table: child.name,
|
|
102
|
+
detail: `${child.name}.${fk.columns.join(', ')} -> ${parent.name}.${fk.refColumns.join(', ')} disagrees with the parent tenant`,
|
|
103
|
+
rows,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// 2b. polymorphic owners: these tables take their site from a soft reference
|
|
109
|
+
// (`Folders.OwnerId`, `Gallery.IdOwner`, the `PostId`/`PageId` host of `Modules`,
|
|
110
|
+
// the counter and watermark legs), so no foreign key can prove the row agrees with
|
|
111
|
+
// its owner and no database can refuse the disagreement (task:3977). Content is
|
|
112
|
+
// never moved between sites, so the risk is the resolution itself: a divergent row
|
|
113
|
+
// would travel to a foreign tenant, a dangling one would travel nowhere (a silent
|
|
114
|
+
// loss), and an ambiguous one carries a guess by declaration order forever.
|
|
115
|
+
const ownerResolve = { divergent: 0, ambiguous: 0, dangling: 0, danglingAllowed: 0, findings: [] };
|
|
116
|
+
for (const root of [...new Set([...POLY_ROOTS, ...Object.keys(ownerSpec.polyRoots ?? {})])]) {
|
|
117
|
+
const links = polyLinks(root, ownerSpec).filter((link) => live.has(root) && live.has(link.table));
|
|
118
|
+
if (!links.length)
|
|
119
|
+
continue;
|
|
120
|
+
// A root may resolve through several legs that are NOT candidates for one value:
|
|
121
|
+
// `GalleryImageSizeWatermark` reads `GalleryImageSize_IdSize` and
|
|
122
|
+
// `Watermark_IdWatermark`, two different columns. Ambiguity is per column — one value
|
|
123
|
+
// matching two tables — so only a column with more than one candidate can be ambiguous.
|
|
124
|
+
const byColumn = new Map();
|
|
125
|
+
for (const link of links)
|
|
126
|
+
byColumn.set(link.column, [...(byColumn.get(link.column) ?? []), link]);
|
|
127
|
+
const contested = [...byColumn.values()].filter((group) => group.length > 1);
|
|
128
|
+
// "this owner id matches a candidate row" — the probe the shapes below count over.
|
|
129
|
+
const matched = links.map((link) => sql `case when exists (select 1 from ${sql.table(link.table)} o where ${sql.id('o', link.pk)} = ${sql.id('c', link.column)}) then 1 else 0 end`);
|
|
130
|
+
const matchedIn = sql `(${sql.join(matched, sql ` + `)})`;
|
|
131
|
+
const candidates = links.map((link) => `${link.column} -> ${link.table}.${link.pk}`).join(', ');
|
|
132
|
+
for (const link of links) {
|
|
133
|
+
checks += 1; // one query per declared resolver
|
|
134
|
+
const rows = readCount(await sql `
|
|
135
|
+
select count(*) as c
|
|
136
|
+
from ${sql.table(root)} c
|
|
137
|
+
join ${sql.table(link.table)} o on ${sql.id('o', link.pk)} = ${sql.id('c', link.column)}
|
|
138
|
+
where ${sql.id('c', 'SiteId')} <> ${sql.id('o', 'SiteId')}
|
|
139
|
+
`.execute(db));
|
|
140
|
+
if (rows > 0) {
|
|
141
|
+
ownerResolve.divergent += rows;
|
|
142
|
+
const detail = `${root}.${link.column} -> ${link.table}.${link.pk}: ${rows} row(s) disagree with the owner's SiteId`;
|
|
143
|
+
ownerResolve.findings.push({ check: 'divergent-tenant-key-vs-owner', table: root, detail, rows });
|
|
144
|
+
violations.push({ check: 'divergent-tenant-key-vs-owner', table: root, detail, rows });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const group of contested) {
|
|
148
|
+
checks += 1;
|
|
149
|
+
const probes = group.map((link) => sql `case when exists (select 1 from ${sql.table(link.table)} o where ${sql.id('o', link.pk)} = ${sql.id('c', link.column)}) then 1 else 0 end`);
|
|
150
|
+
const ambiguous = readCount(await sql `select count(*) as c from ${sql.table(root)} c where (${sql.join(probes, sql ` + `)}) > 1`.execute(db));
|
|
151
|
+
if (ambiguous > 0) {
|
|
152
|
+
ownerResolve.ambiguous += ambiguous;
|
|
153
|
+
ownerResolve.findings.push({
|
|
154
|
+
check: 'ambiguous-owner',
|
|
155
|
+
table: root,
|
|
156
|
+
rows: ambiguous,
|
|
157
|
+
detail: `${root}.${group[0].column}: ${ambiguous} row(s) name an owner that exists in more than one candidate (${group.map((l) => `${l.table}.${l.pk}`).join(', ')}) — the resolver took the first declared match`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// An orphan is only a defect when the contract gives it no other way to a site. A
|
|
162
|
+
// theme folder is the declared exception: no owner, but `*_ThemeId` is a canon FK
|
|
163
|
+
// and the site comes from the theme (task:3976). Anything else resolved nowhere,
|
|
164
|
+
// and a site move would leave it behind without a word.
|
|
165
|
+
const declared = ownerSpec.polyRoots?.[root]?.danglingAllowed;
|
|
166
|
+
const allowedColumns = (declared?.columns ?? []).filter((column) => (live.get(root)?.columns ?? []).some((c) => c.name === column));
|
|
167
|
+
const allowed = allowedColumns.length
|
|
168
|
+
? sql `(${sql.join(allowedColumns.map((column) => sql `case when ${sql.id('c', column)} is not null then 1 else 0 end`), sql ` + `)}) > 0`
|
|
169
|
+
: sql `1 = 0`;
|
|
170
|
+
checks += 1;
|
|
171
|
+
const [orphans = {}] = readRows(await sql `
|
|
172
|
+
select
|
|
173
|
+
sum(case when ${allowed} then 0 else 1 end) as dangling,
|
|
174
|
+
sum(case when ${allowed} then 1 else 0 end) as allowed
|
|
175
|
+
from ${sql.table(root)} c
|
|
176
|
+
where ${matchedIn} = 0
|
|
177
|
+
`.execute(db));
|
|
178
|
+
const dangling = Number(orphans.dangling ?? 0);
|
|
179
|
+
if (dangling > 0) {
|
|
180
|
+
ownerResolve.dangling += dangling;
|
|
181
|
+
const detail = `${root}: ${dangling} row(s) name no owner at all (${candidates}) and carry no declared alternative — nothing carries them to any site`;
|
|
182
|
+
ownerResolve.findings.push({ check: 'dangling-owner', table: root, detail, rows: dangling });
|
|
183
|
+
violations.push({ check: 'dangling-owner', table: root, detail, rows: dangling });
|
|
184
|
+
}
|
|
185
|
+
const allowedRows = Number(orphans.allowed ?? 0);
|
|
186
|
+
if (allowedRows > 0) {
|
|
187
|
+
ownerResolve.danglingAllowed += allowedRows;
|
|
188
|
+
ownerResolve.findings.push({
|
|
189
|
+
check: 'dangling-owner',
|
|
190
|
+
table: root,
|
|
191
|
+
rows: allowedRows,
|
|
192
|
+
allowed: true,
|
|
193
|
+
detail: `${root}: ${allowedRows} row(s) have no owner but the contract allows it — ${allowedColumns.join(' / ')} carries the site (${declared?.reason ?? 'declared exception'})`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { ok: violations.length === 0, checks, violations, ownerResolve };
|
|
198
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* no-DB boot: a registry serving ALL slices from a single JSON fixture root — the engine
|
|
3
|
+
* comes up and renders with no DB connection and no `config.data.sequelize`.
|
|
4
|
+
* @param {string} root fixtures root directory
|
|
5
|
+
* @param {object} [opts] { templatesDir }
|
|
6
|
+
*/
|
|
7
|
+
export function bootFromFixtures(root: string, opts?: object): DataProviderRegistry;
|
|
8
|
+
/**
|
|
9
|
+
* JSON fixture backend. Reads a directory tree that mirrors the entity/template model:
|
|
10
|
+
* <root>/templates/<alias>.template.json schema-as-file template-defs
|
|
11
|
+
* <root>/<slice>/<key>.json entity rows: { id, …meta, template:"<alias>", content:{…} }
|
|
12
|
+
* Resolves each row's template-def (schema-as-file), validates the row's content field names
|
|
13
|
+
* against the template's fields, and returns the DB-loaded entity shape.
|
|
14
|
+
*/
|
|
15
|
+
export class FixtureDataProvider {
|
|
16
|
+
constructor({ root, templatesDir }?: {});
|
|
17
|
+
root: string;
|
|
18
|
+
templatesDir: string;
|
|
19
|
+
_templateCache: Map<any, any>;
|
|
20
|
+
_loadTemplate(alias: any): any;
|
|
21
|
+
_shape(raw: any, slice: any, key: any): any;
|
|
22
|
+
get(slice: any, key: any): Promise<any>;
|
|
23
|
+
enumerate(slice: any): Promise<any[]>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Per-slice routing over N backends. Different slices can come from different providers
|
|
27
|
+
* (e.g. pages from fixtures, store-products from the local DB). `routes` maps a slice to a
|
|
28
|
+
* provider name; `fallback` handles unrouted slices.
|
|
29
|
+
*/
|
|
30
|
+
export class DataProviderRegistry {
|
|
31
|
+
constructor({ providers, routes, fallback }?: {
|
|
32
|
+
providers?: {};
|
|
33
|
+
routes?: {};
|
|
34
|
+
});
|
|
35
|
+
providers: {};
|
|
36
|
+
routes: {};
|
|
37
|
+
fallback: any;
|
|
38
|
+
providerFor(slice: any): any;
|
|
39
|
+
get(slice: any, key: any): any;
|
|
40
|
+
enumerate(slice: any, opts: any): any;
|
|
41
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Pluggable data-source (snolla-dev-workflow Фаза 0). A provider abstraction over the
|
|
2
|
+
// content store so the engine can render from FIXTURES (no DB) in dev, from the local DB, or
|
|
3
|
+
// from prod — chosen per slice. This unblocks the greenfield dev-loop (no Docker/MSSQL) and
|
|
4
|
+
// serves the vendor-agnostic "move off MSSQL" goal. Design: concepts/snolla-dev-workflow.md
|
|
5
|
+
// §Core primitives → Pluggable data-source; depends on schema-as-file (templateDefs.js).
|
|
6
|
+
//
|
|
7
|
+
// PURE of Sequelize/config — lives at the `@snolla/data` floor and is imported by subpath
|
|
8
|
+
// (`@snolla/data/dataProvider.js`) so it does NOT pull the DB barrel (index.js constructs
|
|
9
|
+
// Sequelize at import). That is precisely what makes **no-DB boot** possible: a fixture
|
|
10
|
+
// provider needs neither a connection nor `config.data.sequelize`.
|
|
11
|
+
//
|
|
12
|
+
// CONTRACT — a DataProvider serves "slices" (content collections: 'pages', 'store-products',
|
|
13
|
+
// 'blog-posts', …):
|
|
14
|
+
// provider.get(slice, key) → Promise<row|null> one entity by key (path/slug/id)
|
|
15
|
+
// provider.enumerate(slice, opts) → Promise<row[]> all entities in a slice (listing/sitemap/boot)
|
|
16
|
+
// A returned `row` is **template-shaped = the DB-loaded entity shape** the existing viewModel
|
|
17
|
+
// builders already expect: `{ id, …meta, template: {id,alias,name,content:"<field-defs json>"},
|
|
18
|
+
// content: "<saved-values json>" }`. Producing that shape makes a fixture row a **drop-in** for
|
|
19
|
+
// a Sequelize-loaded row — `getPageViewModel(row, …)` works unchanged, so dev-preview == prod.
|
|
20
|
+
//
|
|
21
|
+
// Фаза-0 scope = the interface + the JSON fixture backend + per-slice routing + no-DB boot.
|
|
22
|
+
// Deferred (documented in concepts/snolla-dev-workflow.md + the task): CSV/SQLite backends,
|
|
23
|
+
// the contentApi prod backend, and rewiring `core/lib/services/*` (the `include:{as:'template'}`
|
|
24
|
+
// path) to source rows from a provider by default. Part/gallery fields still need the file
|
|
25
|
+
// part-template resolver deferred from schema-as-file (fillViewModel:597).
|
|
26
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
27
|
+
import { join, resolve, basename } from 'path';
|
|
28
|
+
import { parseTemplateDef } from './templateDefs.js';
|
|
29
|
+
// Normalize authored fixture `content` to the `[{name,value}]` array fillViewModel consumes.
|
|
30
|
+
// Authors may write the ergonomic object form `{ headline: "Hi" }`; the array form
|
|
31
|
+
// `[{name,value}]` (the DB shape) is also accepted verbatim.
|
|
32
|
+
function normalizeContent(content) {
|
|
33
|
+
if (content == null)
|
|
34
|
+
return [];
|
|
35
|
+
if (Array.isArray(content))
|
|
36
|
+
return content;
|
|
37
|
+
if (typeof content === 'object') {
|
|
38
|
+
return Object.keys(content).map((name) => ({ name, value: content[name] }));
|
|
39
|
+
}
|
|
40
|
+
throw new Error('data-provider: entity "content" must be an object or an array of {name,value}');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* JSON fixture backend. Reads a directory tree that mirrors the entity/template model:
|
|
44
|
+
* <root>/templates/<alias>.template.json schema-as-file template-defs
|
|
45
|
+
* <root>/<slice>/<key>.json entity rows: { id, …meta, template:"<alias>", content:{…} }
|
|
46
|
+
* Resolves each row's template-def (schema-as-file), validates the row's content field names
|
|
47
|
+
* against the template's fields, and returns the DB-loaded entity shape.
|
|
48
|
+
*/
|
|
49
|
+
export class FixtureDataProvider {
|
|
50
|
+
constructor({ root, templatesDir } = {}) {
|
|
51
|
+
if (!root)
|
|
52
|
+
throw new Error('FixtureDataProvider: "root" directory is required');
|
|
53
|
+
this.root = resolve(root);
|
|
54
|
+
this.templatesDir = templatesDir ? resolve(templatesDir) : join(this.root, 'templates');
|
|
55
|
+
this._templateCache = new Map();
|
|
56
|
+
}
|
|
57
|
+
_loadTemplate(alias) {
|
|
58
|
+
if (this._templateCache.has(alias))
|
|
59
|
+
return this._templateCache.get(alias);
|
|
60
|
+
const file = join(this.templatesDir, `${alias}.template.json`);
|
|
61
|
+
if (!existsSync(file)) {
|
|
62
|
+
throw new Error(`FixtureDataProvider: template-def "${alias}" not found at ${file}`);
|
|
63
|
+
}
|
|
64
|
+
const def = parseTemplateDef(JSON.parse(readFileSync(file, 'utf8')));
|
|
65
|
+
this._templateCache.set(alias, def);
|
|
66
|
+
return def;
|
|
67
|
+
}
|
|
68
|
+
// Map a raw fixture row → the DB-loaded entity shape (template-shape). Validates the row's
|
|
69
|
+
// content field names against the template-def (schema-as-file). Rows without a `template`
|
|
70
|
+
// (raw/non-templated data) pass through with content normalized only.
|
|
71
|
+
_shape(raw, slice, key) {
|
|
72
|
+
if (raw.template == null) {
|
|
73
|
+
return { key, ...raw };
|
|
74
|
+
}
|
|
75
|
+
const alias = raw.template;
|
|
76
|
+
const def = this._loadTemplate(alias);
|
|
77
|
+
const fieldNames = new Set(JSON.parse(def.content).map((f) => f.name));
|
|
78
|
+
const contentArr = normalizeContent(raw.content);
|
|
79
|
+
for (const cf of contentArr) {
|
|
80
|
+
if (!fieldNames.has(cf.name)) {
|
|
81
|
+
throw new Error(`data-provider (${slice}/${key}): content field "${cf.name}" is not defined in template "${alias}"`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
key,
|
|
86
|
+
...raw,
|
|
87
|
+
// Carry the full DB-loaded template shape, not just id/alias/name/content:
|
|
88
|
+
// production layout resolution reads `template.customDefaultLayout` (contentPages.js,
|
|
89
|
+
// resolveStoreLayout) — dropping it renders the wrong layout in dev (dev!=prod drift).
|
|
90
|
+
template: {
|
|
91
|
+
id: def.id, alias: def.alias, name: def.name,
|
|
92
|
+
customDefaultLayout: def.customDefaultLayout, layouts: def.layouts,
|
|
93
|
+
content: def.content,
|
|
94
|
+
},
|
|
95
|
+
content: JSON.stringify(contentArr),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async get(slice, key) {
|
|
99
|
+
const file = join(this.root, slice, `${key}.json`);
|
|
100
|
+
if (!existsSync(file))
|
|
101
|
+
return null;
|
|
102
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
103
|
+
return this._shape(raw, slice, key);
|
|
104
|
+
}
|
|
105
|
+
async enumerate(slice) {
|
|
106
|
+
const dir = join(this.root, slice);
|
|
107
|
+
if (!existsSync(dir))
|
|
108
|
+
return [];
|
|
109
|
+
return readdirSync(dir)
|
|
110
|
+
.filter((f) => f.endsWith('.json'))
|
|
111
|
+
.map((f) => {
|
|
112
|
+
const key = basename(f, '.json');
|
|
113
|
+
const raw = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
114
|
+
return this._shape(raw, slice, key);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Per-slice routing over N backends. Different slices can come from different providers
|
|
120
|
+
* (e.g. pages from fixtures, store-products from the local DB). `routes` maps a slice to a
|
|
121
|
+
* provider name; `fallback` handles unrouted slices.
|
|
122
|
+
*/
|
|
123
|
+
export class DataProviderRegistry {
|
|
124
|
+
constructor({ providers = {}, routes = {}, fallback } = {}) {
|
|
125
|
+
this.providers = providers;
|
|
126
|
+
this.routes = routes;
|
|
127
|
+
this.fallback = fallback;
|
|
128
|
+
}
|
|
129
|
+
providerFor(slice) {
|
|
130
|
+
const name = this.routes[slice];
|
|
131
|
+
const provider = name ? this.providers[name] : this.fallback;
|
|
132
|
+
if (!provider) {
|
|
133
|
+
throw new Error(`data-provider: no backend routed for slice "${slice}" and no fallback set`);
|
|
134
|
+
}
|
|
135
|
+
return provider;
|
|
136
|
+
}
|
|
137
|
+
get(slice, key) { return this.providerFor(slice).get(slice, key); }
|
|
138
|
+
enumerate(slice, opts) { return this.providerFor(slice).enumerate(slice, opts); }
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* no-DB boot: a registry serving ALL slices from a single JSON fixture root — the engine
|
|
142
|
+
* comes up and renders with no DB connection and no `config.data.sequelize`.
|
|
143
|
+
* @param {string} root fixtures root directory
|
|
144
|
+
* @param {object} [opts] { templatesDir }
|
|
145
|
+
*/
|
|
146
|
+
export function bootFromFixtures(root, opts = {}) {
|
|
147
|
+
return new DataProviderRegistry({ fallback: new FixtureDataProvider({ root, ...opts }) });
|
|
148
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The cell handles a package opens for itself (task:3933).
|
|
2
|
+
//
|
|
3
|
+
// `core/lib/dataSource.js` is the ENGINE's data source: it turns `config.data.provider` into the
|
|
4
|
+
// process-wide provider the render path reads through. The leaf packages that only depend on
|
|
5
|
+
// `@snolla/data` (numbering, mailer, forms-api) need the same cell — but they cannot import core
|
|
6
|
+
// without inventing a dependency edge, so the cell resolution lives here, next to the seam, and
|
|
7
|
+
// core delegates to it. One config read, one provider per channel per process.
|
|
8
|
+
//
|
|
9
|
+
// Two channels, because the data says so:
|
|
10
|
+
// * `site` — the tenant's own rows (siteId from `config.siteId`, deny-by-default);
|
|
11
|
+
// * `control` — dictionaries and the site registry, tables that carry no SiteId at all.
|
|
12
|
+
// Only the site channel may be written to (the control channel has nothing site-scoped to write).
|
|
13
|
+
//
|
|
14
|
+
// No `data.provider` key → `null`: the deployment still reads through Sequelize, and every caller
|
|
15
|
+
// keeps its legacy path (the same contract core/lib/dataSource.js documents).
|
|
16
|
+
import config from 'config';
|
|
17
|
+
import { DbDataProvider } from './dbProvider.js';
|
|
18
|
+
/** The configured cell (dialect/dsn + this deployment's siteId), or null for the Sequelize path. */
|
|
19
|
+
export const cellConfig = () => {
|
|
20
|
+
if (!config.has('data.provider'))
|
|
21
|
+
return null;
|
|
22
|
+
return { siteId: config.get('siteId'), ...config.get('data.provider') };
|
|
23
|
+
};
|
|
24
|
+
let siteProvider;
|
|
25
|
+
let controlProvider;
|
|
26
|
+
const build = (cell, channel) => {
|
|
27
|
+
let pending = DbDataProvider.fromCell({ ...cell, channel }).catch((error) => {
|
|
28
|
+
// a failed build must not poison the singleton: the next call retries, and the error is
|
|
29
|
+
// the caller's to see rather than a cached rejected promise
|
|
30
|
+
if (channel === 'site')
|
|
31
|
+
siteProvider = undefined;
|
|
32
|
+
else
|
|
33
|
+
controlProvider = undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
36
|
+
return pending;
|
|
37
|
+
};
|
|
38
|
+
/** The tenant provider, or null when no cell is configured. */
|
|
39
|
+
export const getSiteProvider = async () => {
|
|
40
|
+
const cell = cellConfig();
|
|
41
|
+
if (!cell)
|
|
42
|
+
return null;
|
|
43
|
+
if (!siteProvider)
|
|
44
|
+
siteProvider = build(cell, 'site');
|
|
45
|
+
return siteProvider;
|
|
46
|
+
};
|
|
47
|
+
/** The control-plane provider (dictionaries, the site registry), or null with no cell. */
|
|
48
|
+
export const getControlProvider = async () => {
|
|
49
|
+
const cell = cellConfig();
|
|
50
|
+
if (!cell)
|
|
51
|
+
return null;
|
|
52
|
+
if (!controlProvider)
|
|
53
|
+
controlProvider = build(cell, 'control');
|
|
54
|
+
return controlProvider;
|
|
55
|
+
};
|
|
56
|
+
/** Close and forget both handles (tests; a future config reload). */
|
|
57
|
+
export const closeCellProviders = async () => {
|
|
58
|
+
const pending = [siteProvider, controlProvider];
|
|
59
|
+
siteProvider = undefined;
|
|
60
|
+
controlProvider = undefined;
|
|
61
|
+
for (const handle of pending) {
|
|
62
|
+
if (handle)
|
|
63
|
+
await (await handle).close();
|
|
64
|
+
}
|
|
65
|
+
};
|