@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.
Files changed (67) hide show
  1. package/dist/actor.d.ts +28 -0
  2. package/dist/actor.js +63 -0
  3. package/dist/audit.d.ts +36 -0
  4. package/dist/audit.js +198 -0
  5. package/dist/dataProvider.d.ts +41 -0
  6. package/dist/dataProvider.js +148 -0
  7. package/dist/dataSource.d.ts +4 -0
  8. package/dist/dataSource.js +65 -0
  9. package/dist/dbProvider.d.ts +1261 -0
  10. package/dist/dbProvider.js +1898 -0
  11. package/dist/freeze.d.ts +29 -0
  12. package/dist/freeze.js +69 -0
  13. package/dist/guard.d.ts +53 -0
  14. package/dist/guard.js +397 -0
  15. package/dist/insert.d.ts +9 -0
  16. package/dist/insert.js +44 -0
  17. package/dist/migrations/siteid.d.mts +738 -0
  18. package/dist/migrations/siteid.mjs +512 -0
  19. package/dist/publish.d.ts +1 -0
  20. package/dist/publish.js +1 -0
  21. package/dist/schema/dsl.d.ts +64 -0
  22. package/dist/schema/dsl.js +28 -0
  23. package/dist/schema/generate.d.mts +14 -0
  24. package/dist/schema/generate.mjs +282 -0
  25. package/dist/schema/generated/ddl.d.ts +7 -0
  26. package/dist/schema/generated/ddl.js +11 -0
  27. package/dist/schema/generated/kysely-types.d.ts +1988 -0
  28. package/dist/schema/generated/kysely-types.js +5 -0
  29. package/dist/schema/index.d.ts +2 -0
  30. package/dist/schema/index.js +7 -0
  31. package/dist/schema/provision.d.mts +11 -0
  32. package/dist/schema/provision.mjs +111 -0
  33. package/dist/schema/tables.d.ts +2 -0
  34. package/dist/schema/tables.js +3301 -0
  35. package/dist/scripts/canon-audit.d.mts +25 -0
  36. package/dist/scripts/canon-audit.mjs +213 -0
  37. package/dist/scripts/canon-smoke.d.mts +1 -0
  38. package/dist/scripts/canon-smoke.mjs +44 -0
  39. package/dist/scripts/dialect-ddl-stand.d.mts +1 -0
  40. package/dist/scripts/dialect-ddl-stand.mjs +136 -0
  41. package/dist/scripts/owner-spec-check.d.mts +1 -0
  42. package/dist/scripts/owner-spec-check.mjs +166 -0
  43. package/dist/scripts/owners/owner-resolve-spec.json +540 -0
  44. package/dist/scripts/owners-audit.d.mts +60 -0
  45. package/dist/scripts/owners-audit.mjs +443 -0
  46. package/dist/scripts/owners-fixture.d.mts +20 -0
  47. package/dist/scripts/owners-fixture.mjs +192 -0
  48. package/dist/scripts/schema-lengths-audit.d.mts +22 -0
  49. package/dist/scripts/schema-lengths-audit.mjs +146 -0
  50. package/dist/scripts/siteid-migrate.d.mts +7 -0
  51. package/dist/scripts/siteid-migrate.mjs +260 -0
  52. package/dist/scripts/tenant-invariant-audit.d.mts +1 -0
  53. package/dist/scripts/tenant-invariant-audit.mjs +34 -0
  54. package/dist/singleMode.d.ts +5 -0
  55. package/dist/singleMode.js +23 -0
  56. package/dist/sqliteDialect.d.ts +6 -0
  57. package/dist/sqliteDialect.js +92 -0
  58. package/dist/templateDefs.d.ts +38 -0
  59. package/dist/templateDefs.js +163 -0
  60. package/dist/test/fixtures/broken-driver/index.d.ts +3 -0
  61. package/dist/test/fixtures/broken-driver/index.js +5 -0
  62. package/dist/test/support/dialect-stand.d.ts +37 -0
  63. package/dist/test/support/dialect-stand.js +162 -0
  64. package/dist/test/support/render-parity.d.ts +44 -0
  65. package/dist/test/support/render-parity.js +262 -0
  66. package/package.json +50 -5
  67. package/README.md +0 -3
@@ -0,0 +1,22 @@
1
+ export function canonStringColumns(canon?: import("../schema/dsl.ts").CanonSchema): {
2
+ table: string;
3
+ column: string;
4
+ canon: number;
5
+ }[];
6
+ export function liveCharacterColumns(db: any): Promise<{
7
+ table: any;
8
+ column: any;
9
+ length: number;
10
+ }[]>;
11
+ export function isUnbounded(length: any): boolean;
12
+ export function auditStringLengths(live: Array<{
13
+ table: string;
14
+ column: string;
15
+ length: number;
16
+ }>, canon?: object): {
17
+ ok: boolean;
18
+ checked: number;
19
+ canonColumns: number;
20
+ canonOnly: object[];
21
+ violations: object[];
22
+ };
@@ -0,0 +1,146 @@
1
+ // The live-width audit: verifier of task:3889 (the rehearsal incident where
2
+ // `snolla site import` died on `value too long for type character varying(128)`).
3
+ //
4
+ // MSSQL_DSN='Server=…;Database=MoreThenCms;User Id=sa;Password=…;' \
5
+ // node packages/data/scripts/schema-lengths-audit.mjs
6
+ //
7
+ // Why this exists
8
+ // ---------------
9
+ // The canon (`packages/data/schema/tables.ts`) was bootstrapped from the legacy
10
+ // EF6 models, and its `string(n)` widths were taken from the *model*, not from
11
+ // the physical database. On the live schema 402 character columns are declared
12
+ // wider than the canon (usually exactly twice: 50→100, 64→128, 127→255), so a
13
+ // bundle exported from the live data overflows the canon DDL on import. The live
14
+ // schema is the source of truth for widths (plan:49 Q1), which makes the canon
15
+ // the thing that must be widened — the rehearsal `widen.sql` was a workaround,
16
+ // not a fix.
17
+ //
18
+ // This audit compares every `string(n)` column of the canon against the live
19
+ // column of the same name. It is the oracle the widening is verified against:
20
+ // exit 0 = the canon names exactly the live width.
21
+ //
22
+ // A canon `string(n)` column that does not exist in the live schema is NEW canon
23
+ // data (`Sites.Writable`): reported as `canonOnly`, not a
24
+ // violation — there is no live row to copy.
25
+ //
26
+ // Width rules:
27
+ // * `canon < live` — the incident: a bundle from the live data does not fit
28
+ // the canon DDL (task:3889);
29
+ // * `canon > live` — drift in the other direction; the source of truth says the
30
+ // wider canon is not what the data does, so it is a failure too (not silently
31
+ // "safe", because the next reader would trust it);
32
+ // * live unbounded (`nvarchar(max)` / `text`) against a bounded canon column —
33
+ // the canon declares a limit the data never had, so it is `canon < live`.
34
+ //
35
+ // Exit code 1 and a named list on any mismatch; exit 2 when `MSSQL_DSN` is not
36
+ // set — an explicit refusal, never a silent green (plan:49 Q6).
37
+ import { fileURLToPath } from 'node:url';
38
+ import fs from 'node:fs';
39
+ import path from 'node:path';
40
+ import { sql } from 'kysely';
41
+ import { canonicalSchema } from "../schema/index.js";
42
+ /** Every `string(n)` column the canon declares, in canon order. */
43
+ export const canonStringColumns = (canon = canonicalSchema) => {
44
+ const columns = [];
45
+ for (const table of canon.tables) {
46
+ for (const [column, spec] of Object.entries(table.columns)) {
47
+ if (spec.type === 'string' && spec.length !== undefined) {
48
+ columns.push({ table: table.name, column, canon: spec.length });
49
+ }
50
+ }
51
+ }
52
+ return columns;
53
+ };
54
+ /**
55
+ * The live schema's character columns, one object per column. Reading
56
+ * INFORMATION_SCHEMA keeps the audit independent of the ORM and of a physical
57
+ * connection shape: the column width is the fact under audit.
58
+ */
59
+ export const liveCharacterColumns = async (db) => {
60
+ const result = await sql `
61
+ select TABLE_NAME as "table", COLUMN_NAME as "column", CHARACTER_MAXIMUM_LENGTH as "length"
62
+ from INFORMATION_SCHEMA.COLUMNS
63
+ where CHARACTER_MAXIMUM_LENGTH is not null
64
+ `.execute(db);
65
+ return result.rows.map((row) => ({
66
+ table: row.table,
67
+ column: row.column,
68
+ length: Number(row.length),
69
+ }));
70
+ };
71
+ /** A live width that cannot bound anything: `nvarchar(max)` (-1), `text` (2^31-1). */
72
+ export const isUnbounded = (length) => length <= 0 || length >= 2 ** 31 - 1;
73
+ /**
74
+ * Compare every canon `string(n)` against the live column of the same name.
75
+ *
76
+ * @param {Array<{table: string, column: string, length: number}>} live
77
+ * @param {object} [canon] schema to audit (default: the canon)
78
+ * @returns {{ok: boolean, checked: number, canonColumns: number, canonOnly: object[], violations: object[]}}
79
+ */
80
+ export const auditStringLengths = (live, canon = canonicalSchema) => {
81
+ const byKey = new Map(live.map((entry) => [`${entry.table}.${entry.column}`, entry]));
82
+ const declared = canonStringColumns(canon);
83
+ const canonOnly = [];
84
+ const violations = [];
85
+ let checked = 0;
86
+ for (const { table, column, canon: expected } of declared) {
87
+ const actual = byKey.get(`${table}.${column}`);
88
+ if (!actual) {
89
+ canonOnly.push({ table, column, canon: expected });
90
+ continue;
91
+ }
92
+ checked += 1;
93
+ if (isUnbounded(actual.length)) {
94
+ violations.push({ check: 'canon-narrower-than-live', table, column, canon: expected, live: 'max' });
95
+ }
96
+ else if (actual.length > expected) {
97
+ violations.push({ check: 'canon-narrower-than-live', table, column, canon: expected, live: actual.length });
98
+ }
99
+ else if (actual.length < expected) {
100
+ violations.push({ check: 'canon-wider-than-live', table, column, canon: expected, live: actual.length });
101
+ }
102
+ }
103
+ return { ok: violations.length === 0, checked, canonColumns: declared.length, canonOnly, violations };
104
+ };
105
+ // ------------------------------------------------------------------ CLI ---
106
+ const report = async () => {
107
+ const { openDb } = await import('../dbProvider.js');
108
+ const db = await openDb({ dialect: 'mssql', dsn: process.env.MSSQL_DSN });
109
+ try {
110
+ const result = auditStringLengths(await liveCharacterColumns(db));
111
+ console.log(`[lengths] canon string(n)=${result.canonColumns} checked=${result.checked} canon-only=${result.canonOnly.length}`);
112
+ if (result.ok) {
113
+ console.log('PASS schema-lengths audit: every string(n) names the live width');
114
+ return true;
115
+ }
116
+ console.error(`FAIL schema-lengths audit: ${result.violations.length} discrepancy(ies)`);
117
+ for (const v of result.violations.slice(0, 40)) {
118
+ console.error(` - ${v.check} ${v.table}.${v.column}: canon ${v.canon}, live ${v.live}`);
119
+ }
120
+ if (result.violations.length > 40)
121
+ console.error(` … and ${result.violations.length - 40} more`);
122
+ return false;
123
+ }
124
+ finally {
125
+ await db.destroy();
126
+ }
127
+ };
128
+ if (process.argv[1]) {
129
+ const self = fileURLToPath(import.meta.url);
130
+ let direct = false;
131
+ try {
132
+ // resolve symlinks when the entry path exists; fall back to a plain resolve
133
+ // when it does not, so a missing path never crashes the oracle.
134
+ direct = self === fs.realpathSync(process.argv[1]);
135
+ }
136
+ catch {
137
+ direct = self === path.resolve(process.argv[1]);
138
+ }
139
+ if (direct) {
140
+ if (!process.env.MSSQL_DSN) {
141
+ console.error('MSSQL_DSN is not set (connection string of the live MSSQL schema to audit)');
142
+ process.exit(2);
143
+ }
144
+ process.exit((await report()) ? 0 : 1);
145
+ }
146
+ }
@@ -0,0 +1,7 @@
1
+ export function introspect(db: any, { countRows }?: {
2
+ countRows?: any[];
3
+ }): Promise<{
4
+ dialect: string;
5
+ tables: {};
6
+ rowCounts: {};
7
+ }>;
@@ -0,0 +1,260 @@
1
+ // `snolla-data siteid-migrate` — the SiteId migration for a legacy source
2
+ // (task:3872, plan:48 Q5.3). Read-only until `--apply`.
3
+ //
4
+ // node packages/data/scripts/siteid-migrate.mjs --dsn "<mssql dsn>" [--out plan.sql] [--apply] // [--purge --manifest <dir>]
5
+ //
6
+ // The plan comes from the canon (schema/index.ts) plus a live introspection of the
7
+ // source; the source dialect is MSSQL (a cutover source is never a target). Dry-run
8
+ // is the default: printing the plan is how a cutover is reviewed before a copy is
9
+ // mutated. `--apply` runs the statements in order, on the connection given.
10
+ //
11
+ // `--purge` deletes the rows no resolution could place (task:3976, reshaped by task:4092): the
12
+ // sentinel site is gone, so the leftovers are named by the cutover SQL and condemned by an explicit
13
+ // flag. `--manifest <dir>` writes their primary keys to one CSV per table BEFORE anything is
14
+ // removed — a purge nobody can audit is not a purge.
15
+ import fs from 'node:fs';
16
+ import { sql } from 'kysely';
17
+ import { openDb } from '../dbProvider.js';
18
+ import { canonicalSchema } from "../schema/index.js";
19
+ import { planSiteIdMigration, declaredNoneTables, renderMssql, renderPurgeMssql } from '../migrations/siteid.mjs';
20
+ const arg = (name, fallback = null) => {
21
+ const index = process.argv.indexOf(`--${name}`);
22
+ return index >= 0 ? (process.argv[index + 1] ?? true) : fallback;
23
+ };
24
+ /** The source as the planner needs it: tables, columns, PK, foreign keys — plus the row counts of
25
+ * the tables the spec believes empty, because "believes" is not a measurement (task:3998). */
26
+ export const introspect = async (db, { countRows = [] } = {}) => {
27
+ const columns = await db
28
+ .selectFrom('information_schema.columns')
29
+ .select(['table_name', 'column_name', 'is_nullable'])
30
+ .where('table_schema', '=', 'dbo')
31
+ .execute();
32
+ const primary = await db
33
+ .selectFrom('information_schema.key_column_usage as k')
34
+ .innerJoin('information_schema.table_constraints as c', 'c.constraint_name', 'k.constraint_name')
35
+ .select(['k.table_name', 'k.column_name'])
36
+ .where('c.constraint_type', '=', 'PRIMARY KEY')
37
+ .where('k.ordinal_position', '=', 1)
38
+ .execute();
39
+ const foreign = await db
40
+ .selectFrom('sys.foreign_keys as fk')
41
+ .innerJoin('sys.foreign_key_columns as fkc', 'fkc.constraint_object_id', 'fk.object_id')
42
+ .innerJoin('sys.tables as pt', 'pt.object_id', 'fkc.parent_object_id')
43
+ .innerJoin('sys.columns as pc', (join) => join.onRef('pc.object_id', '=', 'fkc.parent_object_id').onRef('pc.column_id', '=', 'fkc.parent_column_id'))
44
+ .innerJoin('sys.tables as rt', 'rt.object_id', 'fkc.referenced_object_id')
45
+ .innerJoin('sys.columns as rc', (join) => join.onRef('rc.object_id', '=', 'fkc.referenced_object_id').onRef('rc.column_id', '=', 'fkc.referenced_column_id'))
46
+ .select(['pt.name as table_name', 'pc.name as column_name', 'rt.name as ref_table', 'rc.name as ref_column'])
47
+ .execute();
48
+ const tables = {};
49
+ const table = (name) => (tables[name] ??= { columns: [], nullable: {}, pk: null, fks: [], indexes: [] });
50
+ for (const row of columns) {
51
+ table(row.table_name).columns.push(row.column_name);
52
+ table(row.table_name).nullable[row.column_name] = row.is_nullable === 'YES';
53
+ }
54
+ for (const row of primary)
55
+ table(row.table_name).pk = row.column_name;
56
+ for (const row of foreign)
57
+ table(row.table_name).fks.push({ columns: [row.column_name], ref: row.ref_table, refColumns: [row.ref_column] });
58
+ // Indexes that sit ON the tenant key (task:4092): SQL Server refuses to tighten a column an
59
+ // index depends on (4922), so the plan has to take them off, tighten, and put them back. The
60
+ // definition comes from the catalog, never from the canon: a legacy unique index
61
+ // (`UK1_Pages`) is a business rule we must restore, not what the canon happens to declare.
62
+ const indexColumns = await db
63
+ .selectFrom('sys.indexes as i')
64
+ .innerJoin('sys.tables as t', 't.object_id', 'i.object_id')
65
+ .innerJoin('sys.index_columns as ic', (join) => join.onRef('ic.object_id', '=', 'i.object_id').onRef('ic.index_id', '=', 'i.index_id'))
66
+ .innerJoin('sys.columns as c', (join) => join.onRef('c.object_id', '=', 'ic.object_id').onRef('c.column_id', '=', 'ic.column_id'))
67
+ .select(['t.name as table_name', 'i.name as index_name', 'i.is_unique', 'i.is_unique_constraint', 'i.has_filter', 'i.filter_definition', 'c.name as column_name', 'ic.key_ordinal', 'ic.is_descending_key', 'ic.is_included_column'])
68
+ .where('i.is_primary_key', '=', false)
69
+ .where('i.is_hypothetical', '=', false)
70
+ .where('t.schema_id', '=', sql `schema_id('dbo')`)
71
+ .orderBy('i.name')
72
+ .orderBy('ic.key_ordinal')
73
+ .execute();
74
+ const indexByName = new Map();
75
+ for (const row of indexColumns) {
76
+ const key = `${row.table_name}|${row.index_name}`;
77
+ const ix = indexByName.get(key) ?? {
78
+ name: row.index_name,
79
+ unique: Boolean(row.is_unique),
80
+ constraint: Boolean(row.is_unique_constraint),
81
+ filter: row.has_filter ? row.filter_definition : null,
82
+ columns: [],
83
+ include: [],
84
+ };
85
+ indexByName.set(key, ix);
86
+ if (row.is_included_column)
87
+ ix.include.push(row.column_name);
88
+ else
89
+ ix.columns.push({ name: row.column_name, descending: Boolean(row.is_descending_key) });
90
+ const owner = table(row.table_name);
91
+ if (!owner.indexes.some((entry) => entry.name === ix.name))
92
+ owner.indexes.push(ix);
93
+ }
94
+ const rowCounts = {};
95
+ for (const name of countRows) {
96
+ // a table the source does not have is not counted: the plan never resolves one either
97
+ if (!tables[name])
98
+ continue;
99
+ const result = await sql `select count(*) as total from ${sql.id('dbo')}.${sql.id(name)}`.execute(db);
100
+ rowCounts[name] = Number(result.rows?.[0]?.total ?? 0);
101
+ }
102
+ return { dialect: 'mssql', tables, rowCounts };
103
+ };
104
+ /** Statement splitter: one statement at a time, comments dropped. */
105
+ const statementsOf = (planSql) => planSql
106
+ .split(/\r?\n/)
107
+ .filter((line) => !line.trim().startsWith('--') && line.trim())
108
+ .join('\n')
109
+ .split(/;\s*\r?\n/)
110
+ .map((s) => s.trim())
111
+ .filter(Boolean);
112
+ /**
113
+ * Writes the primary keys of everything the purge will remove, one CSV per table, and returns the
114
+ * row counts — the audit trail of an irreversible step (task:3976, reshaped by task:4092).
115
+ */
116
+ const writeManifest = async (db, plan, dir) => {
117
+ if (!dir)
118
+ return {};
119
+ fs.mkdirSync(dir, { recursive: true });
120
+ const counts = {};
121
+ for (const op of plan.purge) {
122
+ if (!op.pk)
123
+ continue;
124
+ const rows = await db.selectFrom(op.table).select(op.pk).where('SiteId', 'is', null).execute();
125
+ if (!rows.length)
126
+ continue;
127
+ counts[op.table] = rows.length;
128
+ fs.writeFileSync(`${dir}/${op.table}.csv`, [op.pk, ...rows.map((row) => row[op.pk])].join('\n') + '\n');
129
+ }
130
+ return counts;
131
+ };
132
+ /** One statement at a time: a cutover rehearsal must stop at the first refusal, not leave a
133
+ * half-applied schema behind a single batched driver error. */
134
+ const applyStatements = async (db, statements) => {
135
+ for (let index = 0; index < statements.length; index++) {
136
+ try {
137
+ await sql `${sql.raw(statements[index])}`.execute(db);
138
+ }
139
+ catch (error) {
140
+ console.error(` statement ${index + 1}/${statements.length} failed: ${statements[index].replace(/\s+/g, ' ').slice(0, 240)}`);
141
+ throw error;
142
+ }
143
+ }
144
+ };
145
+ /** Rows still without a site, per table — what the cutover refused to place. */
146
+ const countUnresolved = async (db, plan) => {
147
+ const counts = {};
148
+ for (const op of plan.purge) {
149
+ if (!op.pk)
150
+ continue;
151
+ try {
152
+ const rows = await db.selectFrom(op.table).select(op.pk).where('SiteId', 'is', null).execute();
153
+ if (rows.length)
154
+ counts[op.table] = rows.length;
155
+ }
156
+ catch {
157
+ /* a table the source does not have: the plan never resolves one either */
158
+ }
159
+ }
160
+ return counts;
161
+ };
162
+ const main = async () => {
163
+ const dsn = arg('dsn');
164
+ if (!dsn) {
165
+ console.error('siteid-migrate: --dsn is required (the legacy source; a MSSQL connection string)');
166
+ process.exit(2);
167
+ }
168
+ const db = await openDb({ dialect: 'mssql', dsn });
169
+ try {
170
+ const introspection = await introspect(db, { countRows: declaredNoneTables() });
171
+ const plan = planSiteIdMigration({ canon: canonicalSchema, introspection });
172
+ const { summary } = plan;
173
+ console.log(`siteid-migrate (dry-run${arg('apply') ? ' → APPLY' : ''})`);
174
+ console.log(` add SiteId: ${summary.added} · chained backfills: ${summary.chained} (${summary.links} parent links) · polymorphic roots: ${summary.polymorphic}`);
175
+ console.log(` NOT NULL+index: ${summary.notNull} · unresolved: ${summary.unresolved}`);
176
+ console.log(` declared-none tables with rows: ${summary.declaredNoneWithRows} · order inversions: ${summary.misordered}`);
177
+ // Two checks the plan cannot make for itself: a root the spec declares empty while the
178
+ // source holds rows (task:3998, the spec was only ever compared with a snapshot), and a link
179
+ // whose parent is planted later in the schedule (a JOIN against a still-NULL key is the
180
+ // task:3891 defect one level down). Both mean "fix the plan, do not run".
181
+ if (summary.declaredNoneWithRows) {
182
+ const named = plan.declaredNoneWithRows.map((entry) => `${entry.table} (${entry.rows} rows)`).join(', ');
183
+ console.error(` DECLARED NONE BUT NOT EMPTY (fix the spec or the data, do not run): ${named}`);
184
+ process.exit(1);
185
+ }
186
+ if (summary.misordered) {
187
+ const named = plan.misordered.map((entry) => `${entry.table}←${entry.parent}`).join(', ');
188
+ console.error(` OUT OF ORDER (a link reads a parent planted later, do not run): ${named}`);
189
+ process.exit(1);
190
+ }
191
+ // refuse BEFORE rendering: the sweep parks whatever has no site on the sentinel, so a
192
+ // rendered plan of an unresolved one is a plan that quietly quarantines a whole family.
193
+ // The plan is data; the CLI is where an operator is told not to run it (task:3894).
194
+ if (plan.unresolved.length) {
195
+ console.error(` UNRESOLVED (fix the plan, do not run): ${plan.unresolved.join(', ')}`);
196
+ process.exit(1);
197
+ }
198
+ const planSql = renderMssql(plan);
199
+ const out = arg('out');
200
+ if (out) {
201
+ fs.writeFileSync(out, planSql);
202
+ console.log(` plan written to ${out}`);
203
+ }
204
+ if (arg('apply')) {
205
+ // apply is destructive: the caller is expected to point it at a COPY (plan:48 Q5.3)
206
+ const statements = statementsOf(planSql);
207
+ try {
208
+ await applyStatements(db, statements);
209
+ console.log(` applied ${statements.length} statements`);
210
+ }
211
+ catch (error) {
212
+ // the guard in the cutover SQL stopped on a table that still holds rows with no site.
213
+ // Report the whole set, not the one table the statement order happened to reach.
214
+ const left = await countUnresolved(db, plan);
215
+ const stuck = Object.values(left).reduce((total, count) => total + count, 0);
216
+ console.error(` APPLY STOPPED — ${stuck} rows across ${Object.keys(left).length} tables have no site: ${Object.entries(left).map(([table, n]) => `${table} (${n})`).join(', ')}`);
217
+ console.error(' fix the edge in owner-resolve-spec / the canon FK — or pass --purge --manifest <dir> to delete them');
218
+ process.exitCode = 1;
219
+ return;
220
+ }
221
+ }
222
+ else if (!arg('purge')) {
223
+ console.log(' read-only: pass --apply to run the statements on the source');
224
+ return;
225
+ }
226
+ // ── what the plan could not place (task:4092) ──────────────────────────
227
+ // Nothing is parked: a row that ends the run without a site is an edge missing from the
228
+ // resolver graph, and the cutover SQL has already refused by naming the table. `--purge`
229
+ // is the operator saying "these rows are dead, delete them" — with a manifest of their keys.
230
+ const unresolved = await countUnresolved(db, plan);
231
+ const rows = Object.values(unresolved).reduce((total, count) => total + count, 0);
232
+ if (!arg('purge')) {
233
+ if (rows) {
234
+ console.error(` UNRESOLVED ROWS (${rows} across ${Object.keys(unresolved).length} tables): ${Object.entries(unresolved).map(([table, n]) => `${table} (${n})`).join(', ')}`);
235
+ console.error(' fix the edge in owner-resolve-spec / the canon FK — or pass --purge --manifest <dir> to delete them');
236
+ process.exitCode = 1;
237
+ }
238
+ else {
239
+ console.log(' no rows without a site: nothing is parked, nothing to purge');
240
+ }
241
+ return;
242
+ }
243
+ const manifest = arg('manifest');
244
+ const counts = await writeManifest(db, plan, manifest);
245
+ const doomed = Object.values(counts).reduce((total, count) => total + count, 0);
246
+ console.log(` purge: ${doomed} rows across ${Object.keys(counts).length} tables${manifest ? ` · manifest in ${manifest}` : ' · NO manifest (pass --manifest <dir> to keep the audit trail)'}`);
247
+ const purgeStatements = statementsOf(renderPurgeMssql(plan));
248
+ await applyStatements(db, purgeStatements);
249
+ const left = await countUnresolved(db, plan);
250
+ const remaining = Object.values(left).reduce((total, count) => total + count, 0);
251
+ console.log(` purge: applied ${purgeStatements.length} deletes · rows still without a site: ${remaining}`);
252
+ if (remaining)
253
+ process.exitCode = 1;
254
+ }
255
+ finally {
256
+ await db.destroy();
257
+ }
258
+ };
259
+ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('siteid-migrate.mjs'))
260
+ await main();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ // CLI wrapper over the tenant-invariant audit (task:3814): run it against any
2
+ // canon database — a materialised canon, an imported site, a staging copy.
3
+ //
4
+ // AUDIT_URL=postgres://... node packages/data/scripts/tenant-invariant-audit.mjs
5
+ // AUDIT_DIALECT=mssql AUDIT_URL='sqlserver://sa:pw@host:1433/MoreThenCms_p1?encrypt=false' \
6
+ // node packages/data/scripts/tenant-invariant-audit.mjs
7
+ //
8
+ // Every dialect `openDb` serves (task:3913): postgres, mariadb, mssql. MSSQL is the
9
+ // migration source, and it is where the polymorphic-owner block matters most: the legacy
10
+ // copy is exactly the database whose `Folders`/`Gallery` sites were resolved by a probe
11
+ // (task:3977).
12
+ //
13
+ // Exit code 1 with a JSON report when violations are found — suitable for a
14
+ // cutover checklist step (plan:50 Q6).
15
+ import { auditTenantInvariants } from "../audit.js";
16
+ import { openDb } from '../dbProvider.js';
17
+ const url = process.env.AUDIT_URL;
18
+ if (!url) {
19
+ console.error('AUDIT_URL is not set (connection string of the database to audit)');
20
+ process.exit(2);
21
+ }
22
+ const dialectName = process.env.AUDIT_DIALECT ?? 'postgres';
23
+ // The tooling seam already speaks every dialect with the same DSN syntax the cell does;
24
+ // re-wiring them here was a second copy that drifted (MariaDB was refused while the audit
25
+ // itself had always been dialect-neutral).
26
+ const db = await openDb({ dialect: dialectName, dsn: url });
27
+ try {
28
+ const report = await auditTenantInvariants(db);
29
+ console.log(JSON.stringify(report, null, 2));
30
+ process.exit(report.ok ? 0 : 1);
31
+ }
32
+ finally {
33
+ await db.destroy();
34
+ }
@@ -0,0 +1,5 @@
1
+ import type { Kysely } from 'kysely';
2
+ export interface SingleModeReport {
3
+ siteId: string;
4
+ }
5
+ export declare const assertSingleMode: (db: Kysely<any>) => Promise<SingleModeReport>;
@@ -0,0 +1,23 @@
1
+ export const assertSingleMode = async (db) => {
2
+ // No exceptions: a cell has one site, and the quarantine bucket it used to carry is gone
3
+ // (task:4052 — on a single-tenant database there are no orphan rows to park).
4
+ const sites = await db.selectFrom('Sites').select('SiteId').execute();
5
+ const stores = await db.selectFrom('Stores').select('StoreId').execute();
6
+ const links = await db.selectFrom('AdministratorSites').select('Site_Id').distinct().execute();
7
+ const siteId = sites.length === 1 ? sites[0].SiteId : null;
8
+ const problems = [];
9
+ if (sites.length !== 1)
10
+ problems.push(`${sites.length} sites (single mode expects exactly 1)`);
11
+ if (stores.length > 1)
12
+ problems.push(`${stores.length} stores (single mode expects at most 1)`);
13
+ const foreign = siteId ? [...new Set(links.map((link) => link.Site_Id).filter((id) => id !== siteId))] : [];
14
+ if (foreign.length)
15
+ problems.push(`administrator links to a foreign site: ${foreign.join(', ')}`);
16
+ if (problems.length) {
17
+ // The site ids are the one thing the operator needs to fix the data, and they
18
+ // are not secret — a refusal that hides them costs a debugging session.
19
+ throw new Error(`single mode: this database is not a single-site cell — ${problems.join('; ')}. ` +
20
+ `Clean the rows or serve the database in shared mode.`);
21
+ }
22
+ return { siteId: siteId };
23
+ };
@@ -0,0 +1,6 @@
1
+ export function openSqlite(location?: string): DatabaseSync;
2
+ export function nodeSqliteDialect(database: any): SqliteDialect;
3
+ export function sqliteKysely(location?: string, plugins?: any[]): Kysely<any>;
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { SqliteDialect } from 'kysely';
6
+ import { Kysely } from 'kysely';
@@ -0,0 +1,92 @@
1
+ // node:sqlite as a Kysely dialect (task:3821).
2
+ //
3
+ // SQLite is the third target dialect (plan:49 Q4) and the one that needs no
4
+ // third-party driver: Node ships one. Two gaps between better-sqlite3 (what
5
+ // Kysely's SqliteDialect expects) and node:sqlite are bridged here:
6
+ //
7
+ // * better-sqlite3 marks a statement readable with `reader`; node:sqlite only
8
+ // answers through `columns()`, which throws for anything that returns no rows;
9
+ // * SQLite has no boolean type and node:sqlite refuses to bind a boolean, so a
10
+ // boolean parameter becomes 0/1 at the driver boundary.
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { Kysely, SqliteDialect } from 'kysely';
13
+ const bindable = (parameters) => (parameters ?? []).map((value) => typeof value === 'boolean' ? (value ? 1 : 0)
14
+ // node:sqlite binds neither booleans nor Date objects, and the API layer writes
15
+ // both: a `Date` is a perfectly ordinary value for a `datetime` column (MSSQL
16
+ // took it for years). Stored as the canon's datetime text, in UTC — the Date is
17
+ // an instant, and the reader gets a string back, as it does from every other
18
+ // string column.
19
+ : value instanceof Date ? value.toISOString().replace('T', ' ').replace('Z', '')
20
+ : value);
21
+ class NodeSqliteStatement {
22
+ constructor(statement) {
23
+ this.statement = statement;
24
+ }
25
+ get reader() {
26
+ try {
27
+ this.statement.columns();
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ all(parameters) {
35
+ return this.statement.all(...bindable(parameters));
36
+ }
37
+ run(parameters) {
38
+ const result = this.statement.run(...bindable(parameters));
39
+ return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
40
+ }
41
+ iterate(parameters) {
42
+ return this.statement.iterate(...bindable(parameters));
43
+ }
44
+ }
45
+ class NodeSqliteDatabase {
46
+ #open = true;
47
+ constructor(database) {
48
+ this.database = database;
49
+ }
50
+ prepare(sql) {
51
+ return new NodeSqliteStatement(this.database.prepare(sql));
52
+ }
53
+ /** Idempotent: `destroy()` and the driver's own teardown may both land here. */
54
+ close() {
55
+ if (!this.#open)
56
+ return;
57
+ this.#open = false;
58
+ this.database.close();
59
+ }
60
+ }
61
+ /** Open a node:sqlite database (`:memory:` or a file path). */
62
+ export const openSqlite = (location = ':memory:') => new DatabaseSync(location);
63
+ /** Kysely dialect over an open node:sqlite database. */
64
+ export const nodeSqliteDialect = (database) => new SqliteDialect({ database: new NodeSqliteDatabase(database) });
65
+ /**
66
+ * Kysely over a node:sqlite database whose `destroy()` releases the file even if
67
+ * no query ever ran.
68
+ *
69
+ * Kysely connects lazily and skips the driver's teardown when it never started,
70
+ * while `openSqlite` opens (and Windows locks) the file eagerly. A caller that
71
+ * opens a database and bails before the first query — a cutover tool hitting a
72
+ * broken bundle, a bad flag — would otherwise leave the file locked. So this
73
+ * helper owns the handle and closes it on destroy, once, whoever got there first.
74
+ *
75
+ * `plugins` rides into the Kysely constructor instead of a later `withPlugin`: that call
76
+ * returns a *different* instance, and the wrapper it hands back is not the one whose
77
+ * `destroy()` closes the handle — the file then stays locked on Windows (task:4140).
78
+ */
79
+ export const sqliteKysely = (location = ':memory:', plugins = []) => {
80
+ const database = new NodeSqliteDatabase(openSqlite(location));
81
+ const db = new Kysely({ dialect: new SqliteDialect({ database }), plugins });
82
+ const destroy = db.destroy.bind(db);
83
+ db.destroy = async () => {
84
+ try {
85
+ await destroy();
86
+ }
87
+ finally {
88
+ database.close();
89
+ }
90
+ };
91
+ return db;
92
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Validate + normalize a schema-as-file template-def object into the DB-loaded shape.
3
+ * Pure. Throws on structural / vocabulary errors (the load-time lint seam).
4
+ *
5
+ * @param {object} def parsed file object (see FILE SHAPE above)
6
+ * @returns {{id, kind, alias, name, description, customDefaultLayout, layouts, content}}
7
+ * `content` is `JSON.stringify(def.fields)` — a drop-in for a Sequelize template row.
8
+ */
9
+ export function parseTemplateDef(def: object): {
10
+ id: any;
11
+ kind: any;
12
+ alias: any;
13
+ name: any;
14
+ description: any;
15
+ customDefaultLayout: any;
16
+ layouts: any;
17
+ content: any;
18
+ };
19
+ /**
20
+ * Read + parse a template-def JSON file from disk into the normalized DB-loaded shape.
21
+ * The thin IO wrapper over `parseTemplateDef` (the file-loader the engine plugs in alongside
22
+ * its DB loader).
23
+ *
24
+ * @param {string} filePath absolute or cwd-relative path to a template-def `.json` file
25
+ * @returns {{id, kind, alias, name, description, customDefaultLayout, layouts, content}}
26
+ */
27
+ export function loadTemplateDefFile(filePath: string): {
28
+ id: any;
29
+ kind: any;
30
+ alias: any;
31
+ name: any;
32
+ description: any;
33
+ customDefaultLayout: any;
34
+ layouts: any;
35
+ content: any;
36
+ };
37
+ export const TEMPLATE_KINDS: Set<string>;
38
+ export const FIELD_TEMPLATE_TYPES: Set<string>;