@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,512 @@
1
+ // SiteId migration planner (task:3872, plan:48 Q5.3, requirements:64 п.1).
2
+ //
3
+ // The production copy is the LEGACY schema: the tables that always carried
4
+ // `SiteId` still do, the other canon site tables do not, `Sites` has no
5
+ // `Kind`/`Writable`. A
6
+ // site cannot be exported from that source — the transfer's guard scopes every
7
+ // read by `SiteId`. This module turns the canon plus a live introspection of the
8
+ // source into a deterministic migration plan, in the order a cutover needs:
9
+ //
10
+ // 1. add the missing `SiteId` columns (nullable);
11
+ // 2. resolve the tenant key in ONE parents-first schedule: each table's parent edges as a CHAIN
12
+ // ordered by class (canonical hard FK, the source's own FK, the naming convention) where a
13
+ // link only fills what the previous one left NULL, interleaved with the polymorphic roots
14
+ // (Gallery / Modules / numbering / watermarks, each root planted once every table its
15
+ // `resolvers` read carries a site); nothing is parked on a sentinel (task:4092);
16
+ // 3. enforce `NOT NULL` + index on every added column;
17
+ // 4. add `Sites.Writable`, then NOT NULL + index — a table still holding rows with no site is
18
+ // refused by name, not parked;
19
+ //
20
+ // The plan is pure data — no database — so it is testable and reviewable; the
21
+ // schema is the authority (a table that only exists in the legacy source is never
22
+ // planned), and anything the FK chain cannot reach is reported, not guessed away.
23
+ import { createRequire } from 'node:module';
24
+ import { canonicalSchema, isSiteScoped } from "../schema/index.js";
25
+ const require = createRequire(import.meta.url);
26
+ const ownerSpec = require('../scripts/owners/owner-resolve-spec.json');
27
+ // Polymorphic roots: a site table whose site cannot be read off a single hard FK
28
+ // (a polymorphic owner, a seed, or a union of template tables). The resolver contract lives in
29
+ // `owner-resolve-spec.json`; anything else unresolved is a bug.
30
+ // `Folders` belongs here for the same reason as `Gallery`: its owner is the legacy
31
+ // `IAssetsOwner` (13 implementors, id-probe) — the canon has no FK for it, so the planner used
32
+ // to hand every folder to the sweep (task:3976).
33
+ export const POLY_ROOTS = ['Folders', 'Gallery', 'Modules', '_Modules', 'NumberingCounters', 'GalleryImageSizeWatermark', 'GalleryImagesTemplates'];
34
+ /**
35
+ * The roots that declare `siteFrom: 'none'`: a dictionary with no owner column that nobody
36
+ * populates, so it has no site of its own and every row it holds would be the sweep's. The claim
37
+ * is only true while the table is empty — `declaredNoneWithRows` is where the plan checks it
38
+ * against the source instead of trusting a snapshot of the spec (task:3998).
39
+ */
40
+ export const declaredNoneTables = (spec = ownerSpec) => Object.entries(spec.polyRoots ?? {})
41
+ .filter(([, root]) => root?.siteFrom === 'none')
42
+ .map(([name]) => name);
43
+ /**
44
+ * The order oracle (task:3998): every link must read a parent that carries a site EARLIER in the
45
+ * schedule.
46
+ *
47
+ * Readiness inside the planner is a membership question ("is the parent in `resolved`"), and a
48
+ * set cannot tell earlier from later. The rendered plan is a sequence of UPDATEs, so a link whose
49
+ * parent is planted after it joins against a still-NULL key and the row is quarantined for no
50
+ * reason — the defect of task:3891, one level down. Positions, not sets, are what the renderer
51
+ * obeys; this is the check that says so.
52
+ */
53
+ export const misorderedLinks = (schedule) => {
54
+ const position = new Map();
55
+ schedule.forEach((op, index) => {
56
+ if (!position.has(op.table))
57
+ position.set(op.table, index);
58
+ });
59
+ const found = [];
60
+ schedule.forEach((op, index) => {
61
+ for (const link of op.links ?? []) {
62
+ const parentAt = position.get(link.parent);
63
+ // no position: the parent carried SiteId before the schedule started, or it is not a
64
+ // site table at all — neither is an order this run can get wrong
65
+ if (parentAt !== undefined && parentAt > index)
66
+ found.push({ table: op.table, parent: link.parent });
67
+ }
68
+ });
69
+ return found;
70
+ };
71
+ /**
72
+ * Parents-first order over the hard FKs between the site tables — the mirror of the transfer's
73
+ * insert order, and the order a purge has to REVERSE: a child dies before its parent, or the
74
+ * foreign key refuses the delete.
75
+ */
76
+ const siteFkOrder = (tables) => {
77
+ const byName = new Map(tables.map((table) => [table.name, table]));
78
+ const parentsOf = (name) => (byName.get(name)?.fks ?? [])
79
+ .filter((fk) => !fk.soft && fk.ref !== name && byName.has(fk.ref))
80
+ .map((fk) => fk.ref);
81
+ const order = [];
82
+ const seen = new Set();
83
+ while (order.length < tables.length) {
84
+ const ready = tables.filter((table) => !seen.has(table.name) && parentsOf(table.name).every((parent) => seen.has(parent)));
85
+ // a hard cycle cannot be ordered; take the canon order for what is left rather than spin
86
+ const batch = ready.length ? ready : tables.filter((table) => !seen.has(table.name));
87
+ for (const table of batch) {
88
+ seen.add(table.name);
89
+ order.push(table);
90
+ }
91
+ }
92
+ return order;
93
+ };
94
+ /** The canon as the production copy looked before the migration. */
95
+ export const legacyFromCanon = (canon = canonicalSchema) => {
96
+ const tables = {};
97
+ for (const table of canon.tables) {
98
+ const columns = Object.keys(table.columns).filter((name) => {
99
+ if (table.siteIdAdded && name === 'SiteId')
100
+ return false;
101
+ if (table.name === 'Sites' && name === 'Writable')
102
+ return false;
103
+ return true;
104
+ });
105
+ tables[table.name] = {
106
+ columns,
107
+ pk: Object.entries(table.columns).find(([, column]) => column.pk === true)?.[0] ?? null,
108
+ // the legacy schema has no soft references — those are a canon construct
109
+ fks: (table.fks ?? [])
110
+ .filter((fk) => !fk.soft)
111
+ .map((fk) => ({ columns: fk.columns, ref: fk.ref, refColumns: fk.refColumns })),
112
+ };
113
+ }
114
+ return { dialect: 'mssql', tables };
115
+ };
116
+ /** Introspection: `{ dialect, tables: { Name: { columns, pk, fks } } }` — from a live source or the synthetic one. */ export const planSiteIdMigration = ({ canon = canonicalSchema, introspection, spec = ownerSpec } = {}) => {
117
+ if (!introspection?.tables)
118
+ throw new Error('plan: a source introspection is required');
119
+ const source = introspection.tables;
120
+ const site = canon.tables.filter((t) => isSiteScoped(t) && source[t.name]);
121
+ const siteNames = new Set(site.map((t) => t.name));
122
+ const hasSiteId = (name) => (source[name]?.columns ?? []).includes('SiteId');
123
+ const addColumns = site
124
+ .filter((t) => !hasSiteId(t.name))
125
+ .map((t) => ({ op: 'addColumn', table: t.name, column: 'SiteId', columnType: 'uuid' }));
126
+ // ---- edges: canon hard FKs + live FKs + convention (`XxxId` == a table PK) --
127
+ // An edge is one way to read a site off a parent, and a table may have several. The CLASS is
128
+ // the whole point: a canonical hard FK is the declared owner, a live FK is the source's own
129
+ // constraint, the naming convention is the last resort. The chain is ordered by it
130
+ // (task:3894 — the planner used to keep the FIRST arc the graph happened to walk and drop the
131
+ // rest: `ProductTypes` resolved through an empty `CustomCategoryTypes` while its canonical
132
+ // `Stores` edge was never emitted, and 24/24 rows went to the sentinel with the correct
133
+ // answer one edge away).
134
+ const CANON_FK = 0;
135
+ const LIVE_FK = 1;
136
+ const CONVENTION = 2;
137
+ const edgeByKey = new Map();
138
+ let declaration = 0;
139
+ const addEdge = (rawRank, parent, table, childKey, parentKey) => {
140
+ // a half-edge is nothing to join on, and a key that points at ITSELF (`SiteId` -> `Sites.SiteId`)
141
+ // plants no site — it is already one
142
+ if (!parent || !table || !childKey || !parentKey)
143
+ return;
144
+ // a table's own hierarchy IS a path (task:4092): a `ContentPageParts` row inherits the site
145
+ // of its parent PART, so the self-edge runs LAST — after the table has been planted from
146
+ // its real parents — and only fills what is still NULL
147
+ const rank = parent === table ? CONVENTION + 1 : rawRank;
148
+ const key = `${parent}|${table}|${childKey}|${parentKey}`;
149
+ // the same join seen from two classes is one join — the better class wins, or the plan
150
+ // would render the same UPDATE twice
151
+ if (edgeByKey.has(key))
152
+ return;
153
+ // canonical edges keep the order the canon declares them in — for one child that order is
154
+ // the ranking within the class (the owner column is declared before the weaker link)
155
+ edgeByKey.set(key, { rank, order: rank === CANON_FK ? declaration++ : Number.MAX_SAFE_INTEGER, parent, table, childKey, parentKey });
156
+ };
157
+ for (const table of canon.tables) {
158
+ for (const fk of table.fks ?? [])
159
+ if (!fk.soft)
160
+ addEdge(CANON_FK, fk.ref, table.name, fk.columns[0], fk.refColumns[0]);
161
+ }
162
+ for (const [name, info] of Object.entries(source)) {
163
+ for (const fk of info.fks ?? [])
164
+ addEdge(LIVE_FK, fk.ref, name, fk.columns[0], fk.refColumns[0]);
165
+ }
166
+ const pkOf = new Map(Object.entries(source).map(([name, info]) => [name, info.pk]));
167
+ for (const [name, info] of Object.entries(source)) {
168
+ for (const column of info.columns) {
169
+ if (column === 'SiteId')
170
+ continue;
171
+ const stem = column.replace(/_?Id$/, '');
172
+ if (!stem || stem === column)
173
+ continue;
174
+ // a convention edge is only safe when exactly ONE canon table has that
175
+ // primary key — `TemplateId` points at half a dozen tables otherwise
176
+ const candidates = [...pkOf].filter(([candidate, pk]) => pk === column && candidate !== name && siteNames.has(candidate));
177
+ if (candidates.length === 1)
178
+ addEdge(CONVENTION, candidates[0][0], name, column, column);
179
+ }
180
+ }
181
+ const compareEdges = (a, b) => a.rank - b.rank
182
+ || a.order - b.order
183
+ || a.parent.localeCompare(b.parent)
184
+ || a.childKey.localeCompare(b.childKey);
185
+ const linksInto = new Map();
186
+ for (const edge of [...edgeByKey.values()].sort(compareEdges)) {
187
+ if (!linksInto.has(edge.table))
188
+ linksInto.set(edge.table, []);
189
+ linksInto.get(edge.table).push(edge);
190
+ }
191
+ // ---- the schedule: chains parents-first, interleaved with the polymorphic roots -----
192
+ // `schedule` is the ORDER, and it is the only one: a table may join a parent only after that
193
+ // parent carries a site. Rendering the joins as one block and the roots as another (the shape
194
+ // this replaced) backfilled GalleryImage/GalleryImageSize/Watermark/ModuleProperties from a
195
+ // parent that was still NULL, and the sweep then parked whole families in quarantine
196
+ // (task:3891). The same discipline now covers a table's SECOND parent: a fallback that runs
197
+ // before its primary reads a NULL, and the row is quarantined for no reason (task:3894).
198
+ //
199
+ // `plantable` OVER-approximates on purpose: it is reachability with the order ignored, so a
200
+ // pure FK cycle whose members only reach each other is plantable and still unresolvable — it
201
+ // is reported in `unresolved` (as it must be: nothing in the cycle carries a site) rather
202
+ // than stepped with an empty chain.
203
+ // "The column exists" and "the rows carry a site" are two different facts (task:4092):
204
+ // `FeedPages`, `ContentPageParts` and `_Templates` carry `SiteId` in the legacy source and were
205
+ // therefore given no step at all — whatever stayed NULL went to the sentinel. A table needs a
206
+ // step whenever some parent can hand it a site. "No parent" means: no FK to a
207
+ // site table, no live FK, no convention edge, no poly spec link — the DSL treats `SiteId` as the
208
+ // tenant key itself, so the Blog/Catalog/Store families declare nothing to inherit from. With the
209
+ // key already in the source they are self-evident (their rows were scoped before this migration);
210
+ // without it they are `unresolved` and the run refuses instead of inventing a site (task:4092).
211
+ const parentsOf = (name) => (linksInto.get(name) ?? []).filter((edge) => edge.parent !== 'Sites' && siteNames.has(edge.parent));
212
+ const siteOrder = site.map((t) => t.name);
213
+ const selfEvident = new Set(siteOrder.filter((name) => hasSiteId(name) && !parentsOf(name).length && !POLY_ROOTS.includes(name)));
214
+ const resolved = new Set(['Sites', ...selfEvident]);
215
+ const ordered = site.map((t) => t.name);
216
+ const declaredNone = (name) => spec.polyRoots?.[name]?.siteFrom === 'none';
217
+ // A root is READY only when every table its resolvers read carries a site:
218
+ // `GalleryImageSizeWatermark` hangs off `GalleryImageSize`, which is itself a child of the
219
+ // `Gallery` root. Planting it in the same round as `Gallery` would read a NULL from a parent
220
+ // planted later — the defect of task:3891, one level deeper (task:3876).
221
+ const polyReady = (name, carries) => {
222
+ const links = polyLinks(name, spec);
223
+ if (!links.length)
224
+ return declaredNone(name);
225
+ return links.every((link) => carries.has(link.table));
226
+ };
227
+ // Which added tables can EVER carry a site — pure reachability over the edge graph, no order.
228
+ // An edge from a table that never carries one (`Administrators`) is not a path, and neither is
229
+ // an arc into a table with no path of its own: `resolved` means "the plan carries a path",
230
+ // never "the graph found an arc" (task:3894).
231
+ const plantable = new Set(resolved);
232
+ for (;;) {
233
+ let changed = false;
234
+ for (const name of ordered) {
235
+ if (plantable.has(name) || selfEvident.has(name))
236
+ continue;
237
+ const reachable = POLY_ROOTS.includes(name)
238
+ ? polyReady(name, plantable)
239
+ : (linksInto.get(name) ?? []).some((edge) => plantable.has(edge.parent));
240
+ if (reachable) {
241
+ plantable.add(name);
242
+ changed = true;
243
+ }
244
+ }
245
+ if (!changed)
246
+ break;
247
+ }
248
+ const schedule = [];
249
+ const pending = new Set(ordered.filter((name) => !selfEvident.has(name)));
250
+ // A chain step links only the parents that ALREADY carry a site: every other parent is either
251
+ // still to come (then the table waits for it) or has no path at all (then it is no link).
252
+ const linkKey = (link) => `${link.parent}|${link.childKey}|${link.parentKey}`;
253
+ const linksOf = (name) => (linksInto.get(name) ?? [])
254
+ .filter((edge) => edge.parent === name || resolved.has(edge.parent))
255
+ .map((edge) => ({ parent: edge.parent, childKey: edge.childKey, parentKey: edge.parentKey }));
256
+ // Which links a table has already been given: a cycle break emits a partial chain, and the
257
+ // rest is a catch-up step later, never a link emitted twice.
258
+ const linked = new Map();
259
+ const step = (name) => {
260
+ pending.delete(name);
261
+ resolved.add(name);
262
+ const links = linksOf(name);
263
+ const seen = linked.get(name) ?? new Set();
264
+ linked.set(name, seen);
265
+ for (const link of links)
266
+ seen.add(linkKey(link));
267
+ schedule.push({ op: 'backfillJoin', table: name, links });
268
+ };
269
+ for (;;) {
270
+ let progress = false;
271
+ // A table waits for EVERY parent its chain will read that can carry a site.
272
+ for (;;) {
273
+ const ready = ordered.filter((name) => pending.has(name) && !POLY_ROOTS.includes(name) && plantable.has(name)
274
+ && (linksInto.get(name) ?? []).filter((edge) => plantable.has(edge.parent) && edge.parent !== name).every((edge) => resolved.has(edge.parent)));
275
+ if (!ready.length)
276
+ break;
277
+ for (const name of ready) {
278
+ step(name);
279
+ progress = true;
280
+ }
281
+ }
282
+ for (const name of ordered.filter((n) => pending.has(n) && POLY_ROOTS.includes(n) && polyReady(n, resolved))) {
283
+ const links = polyLinks(name, spec);
284
+ // A root may declare that it has no site of its own (`siteFrom: 'none'`) — a
285
+ // dictionary with no owner column that nobody populates. Then there is nothing to
286
+ // resolve, and the sweep is the whole answer. A root that is neither linked nor
287
+ // declared cannot be resolved at all: it is reported so the run refuses (the CLI
288
+ // exits on a non-empty `unresolved`) instead of quietly handing a whole family to
289
+ // the quarantine site.
290
+ pending.delete(name);
291
+ resolved.add(name);
292
+ schedule.push({ op: 'backfillPoly', table: name, links, declaredNone: declaredNone(name) });
293
+ // A root is not ONLY its owner probe: `Folders` also hangs off `*_ThemeId` (canon FK),
294
+ // and a theme folder carries no owner at all. Leaving the chained half out dropped 768
295
+ // theme folders — and the 10 827 files beneath them — into the purge (task:3976).
296
+ const chained = linksOf(name).map((link) => ({ parent: link.parent, childKey: link.childKey, parentKey: link.parentKey }));
297
+ if (chained.length) {
298
+ const seen = linked.get(name) ?? new Set();
299
+ linked.set(name, seen);
300
+ for (const link of chained)
301
+ seen.add(linkKey(link));
302
+ schedule.push({ op: 'backfillJoin', table: name, links: chained });
303
+ }
304
+ progress = true;
305
+ }
306
+ if (progress)
307
+ continue;
308
+ // Work is left and nothing is ready: the remaining tables wait on each other (a mutual FK
309
+ // pair — `FeedParams`/`FeedPageParams`). One deterministic step (canon order) breaks the
310
+ // cycle, so an honest plan always exists and the run terminates. A POLYMORPHIC root is
311
+ // never a candidate: its site comes from its own contract, and guessing one off a passing
312
+ // FK is exactly the silent guess task:3891 removed. A pair with no root at all has no
313
+ // site to inherit (that is not a cycle to break) and lands in `unresolved`.
314
+ const stuck = ordered.find((name) => pending.has(name) && plantable.has(name) && !POLY_ROOTS.includes(name)
315
+ && (linksInto.get(name) ?? []).some((edge) => resolved.has(edge.parent)));
316
+ if (stuck === undefined)
317
+ break;
318
+ step(stuck);
319
+ }
320
+ // A table stepped by the cycle break linked only what carried a site at that moment, so its
321
+ // remaining parents — resolved by now, every table that can carry a site is — are emitted as
322
+ // ONE catch-up step at the end. A fallback link that never runs leaves rows in quarantine the
323
+ // plan itself could have resolved (task:3894). A table that is never stepped at all has no
324
+ // resolved parent (or it would be plantable), so nothing is emitted for it here.
325
+ for (const name of ordered) {
326
+ // self-evident tables were given no step on purpose: they have no parent to inherit from
327
+ if (pending.has(name) || selfEvident.has(name) || POLY_ROOTS.includes(name))
328
+ continue;
329
+ const seen = linked.get(name) ?? new Set();
330
+ const missing = linksOf(name).filter((link) => !seen.has(linkKey(link)));
331
+ if (missing.length)
332
+ schedule.push({ op: 'backfillJoin', table: name, links: missing });
333
+ }
334
+ const unresolved = [...pending].sort();
335
+ // The plan's two named views of the schedule. They are derived, never accumulated
336
+ // separately: a second list is a second order, and the second order was wrong.
337
+ const backfills = schedule.filter((op) => op.op === 'backfillJoin');
338
+ const poly = schedule.filter((op) => op.op === 'backfillPoly');
339
+ // There is NO sweep and no sentinel (task:4092). A row that ends the run without a site is a
340
+ // missing edge in the resolver graph, not data to be filed away: parking it made the loss
341
+ // invisible (169 175 rows on a green run) and hid the real defect — two tables that already
342
+ // carried `SiteId` were never backfilled at all. The renderer refuses instead, naming the
343
+ // table, and `renderPurgeMssql` deletes only what an operator explicitly condemns.
344
+ const purge = [...siteFkOrder(site)].reverse().map((table) => ({ op: 'purgeNull', table: table.name, pk: pkOf.get(table.name) ?? null }));
345
+ // NOT NULL and the index are the target shape for EVERY site table, not only the ones this run
346
+ // gave a column to: a key the catalog still reports nullable is the same defect as a missing one
347
+ // (task:4092). The canon says NOT NULL everywhere, so the migration owes the database that shape.
348
+ const siteNamesSorted = [...siteNames].sort();
349
+ const keyIsNullable = (name) => !hasSiteId(name) || source[name]?.nullable?.SiteId === true;
350
+ // An index on the key blocks the ALTER (SQL Server 4922), so it comes off first and goes back
351
+ // afterwards — with the definition the catalog gave, not one the canon guessed: `UK1_Pages` is a
352
+ // business rule (unique per site and path) and dropping it for good would be silent damage.
353
+ const indexesOnKey = (name) => (source[name]?.indexes ?? [])
354
+ .filter((ix) => ix.columns.some((column) => column.name === 'SiteId') || (ix.include ?? []).includes('SiteId'))
355
+ .map((ix) => ({
356
+ name: ix.name,
357
+ unique: Boolean(ix.unique),
358
+ constraint: Boolean(ix.constraint),
359
+ columns: ix.columns,
360
+ include: ix.include ?? [],
361
+ filter: ix.filter ?? null,
362
+ }));
363
+ const notNull = siteNamesSorted
364
+ .filter(keyIsNullable)
365
+ .map((table) => ({ op: 'setNotNull', table, column: 'SiteId', rebuild: indexesOnKey(table) }));
366
+ const indexes = siteNamesSorted.map((table) => ({ op: 'createIndex', table, column: 'SiteId', name: `IX_${table}_SiteId` }));
367
+ // A declared-none root is a promise that the table is empty. Check it against the source the
368
+ // plan was built from (the CLI measures those tables with `introspect({ countRows })`), because
369
+ // the promise was otherwise only compared with a snapshot in `owner-spec-check` — and a row in
370
+ // a dictionary believed empty goes to the quarantine with nothing in the run to notice
371
+ // (task:3998). The plan still carries the step; the CLI is where the run is refused.
372
+ const rowCounts = introspection.rowCounts ?? {};
373
+ const declaredNoneWithRows = declaredNoneTables(spec)
374
+ .filter((name) => source[name])
375
+ .map((name) => ({ table: name, rows: Number(rowCounts[name] ?? 0) }))
376
+ .filter((entry) => entry.rows > 0);
377
+ const misordered = misorderedLinks(schedule);
378
+ return {
379
+ addColumns,
380
+ // the order a run must follow: joins and poly roots interleaved parents-first
381
+ schedule,
382
+ selfEvident: [...selfEvident].sort(),
383
+ backfills,
384
+ poly,
385
+ purge,
386
+ notNull,
387
+ indexes,
388
+ registry: {
389
+ addColumns: [
390
+ { op: 'addColumn', table: 'Sites', column: 'Writable', columnType: 'bool' },
391
+ ],
392
+ default: { op: 'backfillRegistry', table: 'Sites', writable: true },
393
+ },
394
+ unresolved,
395
+ declaredNoneWithRows,
396
+ misordered,
397
+ summary: {
398
+ added: addColumns.length,
399
+ chained: backfills.length,
400
+ links: backfills.reduce((total, op) => total + op.links.length, 0),
401
+ polymorphic: poly.length,
402
+ purge: purge.length,
403
+ notNull: notNull.length,
404
+ unresolved: unresolved.length,
405
+ declaredNoneWithRows: declaredNoneWithRows.length,
406
+ misordered: misordered.length,
407
+ },
408
+ };
409
+ };
410
+ /**
411
+ * The `(owner column -> table, pk)` links a polymorphic root resolves through.
412
+ *
413
+ * The spec is the contract (task:3876): a root declares its `resolvers` explicitly — a parent
414
+ * chain for watermarks, the soft owner references for counters, the polymorphic owner list for
415
+ * galleries and modules. There is NO fallback: a root the spec does not describe resolves through
416
+ * nothing, lands in `unresolved`, and the CLI refuses to run the plan — a spec edit that forgets a
417
+ * root must fail loudly, not silently probe whatever `observedOwners` happened to list first
418
+ * (task:3895: `Gallery` and `_Modules` leaned on exactly that implicit order).
419
+ */
420
+ export const polyLinks = (name, spec) => (spec.polyRoots?.[name]?.resolvers ?? []).filter((link) => link?.column && link?.table && link?.pk);
421
+ /** MSSQL rendering of the plan — the source dialect of a cutover. */
422
+ const dropIndexSql = (table, index) => (index.constraint
423
+ ? `ALTER TABLE [dbo].[${table}] DROP CONSTRAINT [${index.name}];`
424
+ : `DROP INDEX [${index.name}] ON [dbo].[${table}];`);
425
+ const createIndexSql = (table, index) => {
426
+ const columns = index.columns.map((column) => `[${column.name}]${column.descending ? ' DESC' : ''}`).join(', ');
427
+ const include = index.include?.length ? ` INCLUDE (${index.include.map((name) => `[${name}]`).join(', ')})` : '';
428
+ const filter = index.filter ? ` WHERE ${index.filter}` : '';
429
+ return index.constraint
430
+ ? `ALTER TABLE [dbo].[${table}] ADD CONSTRAINT [${index.name}] UNIQUE (${columns});`
431
+ : `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX [${index.name}] ON [dbo].[${table}] (${columns})${include}${filter};`;
432
+ };
433
+ export const renderMssql = (plan) => {
434
+ const lines = ['-- snolla: SiteId migration (generated from the canon, task:3872). Run on a COPY.', ''];
435
+ const mssqlType = (type) => ({ uuid: 'UNIQUEIDENTIFIER', string: 'NVARCHAR(20)', bool: 'BIT' })[type] ?? 'NVARCHAR(255)';
436
+ // the plan states the tenant key's type once; step 1 and step 5 must not be able to disagree
437
+ // about it (task:3898: step 5 hardcoded UNIQUEIDENTIFIER while step 1 read the spec)
438
+ const keyTypes = new Map(plan.addColumns.map((op) => [op.table, op.columnType]));
439
+ const [writableColumn] = plan.registry.addColumns.map((op) => op.column);
440
+ const literal = (value) => (typeof value === 'boolean' ? (value ? 1 : 0) : `'${value}'`);
441
+ // every step below is guarded: a rehearsal is re-run after an interruption, and the purge is a
442
+ // decision taken after the apply — neither may need the whole plan to be a no-op (task:3996)
443
+ lines.push('-- step 1: add the tenant key (nullable first)');
444
+ for (const op of plan.addColumns) {
445
+ lines.push(`IF COL_LENGTH('dbo.${op.table}','SiteId') IS NULL ALTER TABLE [dbo].[${op.table}] ADD [SiteId] ${mssqlType(op.columnType)} NULL;`);
446
+ }
447
+ lines.push('', '-- step 2: resolve the tenant key parents-first — a polymorphic root plants its\n-- children, so the root is resolved BEFORE the joins that read it (task:3891)');
448
+ for (const op of plan.schedule) {
449
+ if (op.op === 'backfillJoin') {
450
+ if ((op.links ?? []).length > 1) {
451
+ lines.push(`-- [dbo].[${op.table}]: ${op.links.length} candidate parents, the declared owner first;\n-- each fallback only fills what the previous link left NULL (task:3894)`);
452
+ }
453
+ for (const link of op.links ?? []) {
454
+ // `SiteId -> Sites.SiteId` is the key reading itself: it carries the table in the
455
+ // readiness graph, and it writes nothing
456
+ if (link.childKey === 'SiteId' && link.parentKey === 'SiteId')
457
+ continue;
458
+ lines.push(`UPDATE c SET c.[SiteId] = p.[SiteId] FROM [dbo].[${op.table}] c JOIN [dbo].[${link.parent}] p ON p.[${link.parentKey}] = c.[${link.childKey}] WHERE c.[SiteId] IS NULL;`);
459
+ }
460
+ continue;
461
+ }
462
+ const links = (op.links ?? []).filter((link) => link.table && link.pk && link.column);
463
+ if (!links.length) {
464
+ lines.push(op.declaredNone
465
+ ? `-- DECLARED NONE: [dbo].[${op.table}] carries no site of its own (owner-resolve-spec siteFrom: none) — any row it holds is the sweep's.`
466
+ : `-- MANUAL: [dbo].[${op.table}] — no single owner column in the spec; resolve from the legacy link (owner-resolve-spec.json).`);
467
+ }
468
+ for (const link of links) {
469
+ lines.push(`UPDATE c SET c.[SiteId] = o.[SiteId] FROM [dbo].[${op.table}] c JOIN [dbo].[${link.table}] o ON o.[${link.pk}] = c.[${link.column}] WHERE c.[SiteId] IS NULL;`);
470
+ }
471
+ }
472
+ lines.push('', '-- step 3: the registry flag — every real site is writable (nothing is parked, task:4092)');
473
+ // the registry DDL is rendered from `plan.registry`: the plan states it, the renderer prints it
474
+ // (task:3898 — the same DDL written in two places drifts apart silently)
475
+ for (const op of plan.registry.addColumns) {
476
+ lines.push(`IF COL_LENGTH('dbo.${op.table}','${op.column}') IS NULL ALTER TABLE [dbo].[${op.table}] ADD [${op.column}] ${mssqlType(op.columnType)} NULL;`);
477
+ }
478
+ lines.push(`UPDATE [dbo].[${plan.registry.default.table}] SET [${writableColumn}] = ${literal(plan.registry.default.writable)};`);
479
+ lines.push('', '-- step 4: enforce NOT NULL + index — a table still holding rows with no site THROWS and', '-- names itself. A dangling row is a missing edge in the resolver graph (task:4092); parking', '-- it on a sentinel site is what made the loss invisible while the run reported green.');
480
+ for (const op of plan.notNull) {
481
+ lines.push(`IF EXISTS (SELECT 1 FROM [dbo].[${op.table}] WHERE [SiteId] IS NULL) THROW 50000, '${op.table}: rows with no site — fix the edge (owner-resolve-spec / canon FK), do not park them', 1;`);
482
+ // an index that sits on the key blocks the ALTER (4922): off, tighten, back on — the definition
483
+ // is the catalog's, so a legacy unique constraint survives the trip
484
+ for (const index of op.rebuild ?? [])
485
+ lines.push(dropIndexSql(op.table, index));
486
+ lines.push(`ALTER TABLE [dbo].[${op.table}] ALTER COLUMN [SiteId] ${mssqlType(keyTypes.get(op.table) ?? 'uuid')} NOT NULL;`);
487
+ for (const index of op.rebuild ?? [])
488
+ lines.push(createIndexSql(op.table, index));
489
+ }
490
+ for (const op of plan.indexes) {
491
+ lines.push(`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE [name] = '${op.name}' AND [object_id] = OBJECT_ID('dbo.${op.table}')) CREATE INDEX [${op.name}] ON [dbo].[${op.table}] ([SiteId]);`);
492
+ }
493
+ return lines.join('\n') + '\n';
494
+ };
495
+ /**
496
+ * The explicit deletion pass (task:3976, reshaped by task:4092) — the destructive half, rendered
497
+ * separately on purpose.
498
+ *
499
+ * It deletes exactly the rows the plan could not place, and nothing else: no sentinel site, no
500
+ * invented tenant. Children first — the order is the reverse of the parents-first one, because a
501
+ * hard FK refuses the other order. An operator runs this only after reading the refusal the
502
+ * cutover SQL raised: every run of it is a decision that these rows are dead, not misplaced.
503
+ */
504
+ export const renderPurgeMssql = (plan) => {
505
+ const lines = ['-- snolla: delete the rows with no site (task:4092). Children first — a hard FK refuses the other order.', ''];
506
+ for (const op of plan.purge ?? []) {
507
+ if (!op.pk)
508
+ continue;
509
+ lines.push(`DELETE FROM [dbo].[${op.table}] WHERE [SiteId] IS NULL;`);
510
+ }
511
+ return lines.join('\n') + '\n';
512
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ export declare const CANON_TYPES: readonly ["uuid", "string", "text", "int", "bigint", "bool", "decimal", "datetime", "date", "binary", "json", "enum"];
2
+ export type CanonType = (typeof CANON_TYPES)[number];
3
+ /**
4
+ * How a row gets into a database during a move (task:3936, design wiki:3874 §2).
5
+ *
6
+ * One schema serves every mode — a row is not in "another database" because of its
7
+ * scope. The labels say how a transfer brings it along:
8
+ * * `site` — carried by the row's own `SiteId`, with the tenant's ordinary tables;
9
+ * * `control` / `registry` / `dictionary` — carries no `SiteId`; provisioned by the
10
+ * rows that reference it (or by the cutover), so a bundle does not carry it whole.
11
+ */
12
+ export type TableScope = 'site' | 'control' | 'registry' | 'dictionary';
13
+ export interface ColumnSpec {
14
+ type: CanonType;
15
+ /** for `string`: max characters (dialect-neutral, characters not bytes). */
16
+ length?: number;
17
+ /** for `decimal`: total digits. */
18
+ precision?: number;
19
+ /** for `decimal`: digits after the point. */
20
+ scale?: number;
21
+ /** default false — NOT NULL unless stated otherwise. */
22
+ nullable?: boolean;
23
+ pk?: boolean;
24
+ /** canonical default expression in canon form (dialect-neutral), if any. */
25
+ default?: string;
26
+ }
27
+ export interface IndexSpec {
28
+ columns: string[];
29
+ unique?: boolean;
30
+ }
31
+ export interface FkSpec {
32
+ columns: string[];
33
+ ref: string;
34
+ refColumns: string[];
35
+ /**
36
+ * Soft reference (plan:48 R1-Q3): the column keeps pointing at a
37
+ * control-plane row but the FK is not enforced in the site database —
38
+ * audit links to `Administrators` and the like.
39
+ */
40
+ soft?: boolean;
41
+ }
42
+ export interface TableSpec {
43
+ name: string;
44
+ scope: TableScope;
45
+ /** true for the 81 tables that gain `SiteId` during the migration. */
46
+ siteIdAdded?: boolean;
47
+ columns: Record<string, ColumnSpec>;
48
+ indexes?: IndexSpec[];
49
+ fks?: FkSpec[];
50
+ }
51
+ export interface SiteSeed {
52
+ SiteId: string;
53
+ Alias: string;
54
+ Title: string;
55
+ Writable: boolean;
56
+ Live: boolean;
57
+ Production: boolean;
58
+ }
59
+ export interface CanonSchema {
60
+ /** revision of the canon; a bump is a migration (plan:49 Q5). */
61
+ version: number;
62
+ tables: TableSpec[];
63
+ }
64
+ export declare const isSiteScoped: (t: TableSpec) => boolean;
@@ -0,0 +1,28 @@
1
+ // Canonical schema DSL (task:3812, R2 decisions — plan:49 Q2/Q3/Q4).
2
+ //
3
+ // THE canon is a typed, dialect-neutral description of the product schema.
4
+ // Generators derive from it (task:3820): Kysely `Database` types, per-dialect
5
+ // DDL, the temporary Prisma bridge. Nothing else may describe the schema —
6
+ // hand-written duplicates are what this file exists to remove.
7
+ //
8
+ // Conventions:
9
+ // * identifiers are PascalCase, exactly as in the legacy DB (plan:49 Q3) —
10
+ // no renaming tax in ETL or in diffing against the live database;
11
+ // * column types come from the 12-type vocabulary below (plan:49 Q4); a type
12
+ // outside it is a canon bug, not a convenience;
13
+ // * `nullable: false` is the default: a nullable column says so explicitly.
14
+ export const CANON_TYPES = [
15
+ 'uuid',
16
+ 'string',
17
+ 'text',
18
+ 'int',
19
+ 'bigint',
20
+ 'bool',
21
+ 'decimal',
22
+ 'datetime',
23
+ 'date',
24
+ 'binary',
25
+ 'json',
26
+ 'enum',
27
+ ];
28
+ export const isSiteScoped = (t) => t.scope === 'site';
@@ -0,0 +1,14 @@
1
+ export const DIALECTS: string[];
2
+ export function mapType(column: any, dialect: any): any;
3
+ /** Postgres refuses identifiers longer than 63 characters; it is the strictest dialect. */
4
+ export const MAX_IDENTIFIER_LENGTH: 63;
5
+ export function indexName(base: any, index?: number): any;
6
+ export function constraintName(table: any, fk: any, index: any): any;
7
+ export function renderDdlSql(dialect: any): string;
8
+ export function renderDdlModule(): string;
9
+ export function renderKyselyTypes(): string;
10
+ export const ARTIFACTS: {
11
+ 'ddl.ts': () => string;
12
+ 'kysely-types.ts': () => string;
13
+ };
14
+ export function writeAll(): Promise<void>;