@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
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// The R1 owner-resolution audit: verifier of task:3815, moved into the
|
|
2
|
+
// repository by task:3863 so that its central proof is reproducible.
|
|
3
|
+
//
|
|
4
|
+
// node packages/data/scripts/owners-audit.mjs
|
|
5
|
+
// MSSQL_AUDIT_URL=… node packages/data/scripts/owners-audit.mjs --source=mssql
|
|
6
|
+
//
|
|
7
|
+
// `--source=fixture` (the default) runs the checks against the synthetic,
|
|
8
|
+
// deterministic SQLite fixture built from the contract itself — no database, no
|
|
9
|
+
// secrets, same report on every machine — and, unless `--no-compare` is passed,
|
|
10
|
+
// pins that report to the committed production dry-run (`owners/prod-audit-report.json`),
|
|
11
|
+
// so a "green" fixture cannot outlive a contract edit that production never saw.
|
|
12
|
+
// `--source=mssql` runs exactly the same checks, read-only, against a live MSSQL
|
|
13
|
+
// source (the verified local copy of production, or production itself, in check
|
|
14
|
+
// queries only); pass `--compare` to pin it to the committed report too.
|
|
15
|
+
//
|
|
16
|
+
// Any discrepancy outside the quarantine is a non-zero exit: the audit is the
|
|
17
|
+
// oracle, so `--expect-zero` is not a mode, it is the only behaviour.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { createOwnersFixture, loadContract } from './owners-fixture.mjs';
|
|
21
|
+
export const REPO_CONTRACT = loadContract();
|
|
22
|
+
export const PROD_EVIDENCE = fileURLToPath(new URL('./owners/prod-audit-report.json', import.meta.url));
|
|
23
|
+
const lower = (s) => String(s).toLowerCase();
|
|
24
|
+
const num = (v) => Number(v ?? 0);
|
|
25
|
+
// ---------------------------------------------------------------- adapters ---
|
|
26
|
+
/** SQLite adapter over a `node:sqlite` handle (the fixture, or a file copy). */
|
|
27
|
+
export const sqliteAdapter = (db, { source = 'fixture', target = 'in-memory owners fixture' } = {}) => ({
|
|
28
|
+
source,
|
|
29
|
+
target,
|
|
30
|
+
quote: (name) => `"${String(name).replaceAll('"', '""')}"`,
|
|
31
|
+
query: async (sql) => db.prepare(sql).all(),
|
|
32
|
+
tableColumns: async (tables) => {
|
|
33
|
+
const map = new Map();
|
|
34
|
+
for (const table of tables) {
|
|
35
|
+
map.set(lower(table), new Set(db.prepare('SELECT name FROM pragma_table_info(?)').all(table).map((r) => lower(r.name))));
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
},
|
|
39
|
+
tablesWithColumn: async (column) => {
|
|
40
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => String(r.name));
|
|
41
|
+
const found = new Set();
|
|
42
|
+
for (const table of tables) {
|
|
43
|
+
const has = db.prepare('SELECT name FROM pragma_table_info(?)').all(table).some((r) => lower(r.name) === lower(column));
|
|
44
|
+
if (has)
|
|
45
|
+
found.add(lower(table));
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
},
|
|
49
|
+
close: async () => { },
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Read-only MSSQL adapter. The URL comes from the environment (`MSSQL_AUDIT_URL`),
|
|
53
|
+
* never from the repository: credentials are a `secret:`-ref, not a file
|
|
54
|
+
* (canon/gates Г6). Both the legacy `sqlserver://host:port;key=value;…` form and
|
|
55
|
+
* the `sqlserver://user:pass@host:port/database?…` form are accepted.
|
|
56
|
+
*/
|
|
57
|
+
export const parseMssqlUrl = (url) => {
|
|
58
|
+
const stripped = String(url).replace(/^sqlserver:\/\//i, '').replace(/^mssql:\/\//i, '');
|
|
59
|
+
const queryAt = stripped.indexOf('?');
|
|
60
|
+
const body = queryAt < 0 ? stripped : stripped.slice(0, queryAt);
|
|
61
|
+
const queryString = queryAt < 0 ? '' : stripped.slice(queryAt + 1);
|
|
62
|
+
const [authorityPart, ...tail] = body.split(';');
|
|
63
|
+
// URI spelling: [user[:password]@]host[:port][/database], and a password may
|
|
64
|
+
// itself contain `@` (the userinfo ends at the LAST `@`) or `:` (only the
|
|
65
|
+
// first `:` separates user from password).
|
|
66
|
+
const slash = authorityPart.indexOf('/');
|
|
67
|
+
const authority = slash < 0 ? authorityPart : authorityPart.slice(0, slash);
|
|
68
|
+
const databasePath = slash < 0 ? '' : authorityPart.slice(slash + 1);
|
|
69
|
+
const at = authority.lastIndexOf('@');
|
|
70
|
+
const userinfo = at < 0 ? '' : authority.slice(0, at);
|
|
71
|
+
const hostPort = at < 0 ? authority : authority.slice(at + 1);
|
|
72
|
+
const [host, port] = hostPort.split(':');
|
|
73
|
+
const kv = {};
|
|
74
|
+
for (const chunk of tail) {
|
|
75
|
+
const i = chunk.indexOf('=');
|
|
76
|
+
if (i > 0)
|
|
77
|
+
kv[lower(chunk.slice(0, i)).trim()] = chunk.slice(i + 1).trim();
|
|
78
|
+
}
|
|
79
|
+
for (const chunk of queryString.split('&')) {
|
|
80
|
+
const i = chunk.indexOf('=');
|
|
81
|
+
if (i > 0)
|
|
82
|
+
kv[lower(chunk.slice(0, i))] = chunk.slice(i + 1);
|
|
83
|
+
}
|
|
84
|
+
const dec = (s) => { try {
|
|
85
|
+
return decodeURIComponent(s ?? '');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return s ?? '';
|
|
89
|
+
} };
|
|
90
|
+
const colon = userinfo.indexOf(':');
|
|
91
|
+
return {
|
|
92
|
+
server: host,
|
|
93
|
+
port: Number(port || 1433),
|
|
94
|
+
database: dec(kv.database ?? databasePath) || undefined,
|
|
95
|
+
user: dec(colon < 0 ? userinfo : userinfo.slice(0, colon)) || dec(kv.user),
|
|
96
|
+
password: dec(colon < 0 ? '' : userinfo.slice(colon + 1)) || dec(kv.password),
|
|
97
|
+
encrypt: kv.encrypt !== 'false',
|
|
98
|
+
trustServerCertificate: kv.trustservercertificate === 'true',
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
export const mssqlAdapter = async ({ url, source = 'mssql', target } = {}) => {
|
|
102
|
+
const { default: tedious } = await import('tedious');
|
|
103
|
+
const cfg = parseMssqlUrl(url);
|
|
104
|
+
const connection = new tedious.Connection({
|
|
105
|
+
server: cfg.server,
|
|
106
|
+
options: {
|
|
107
|
+
port: cfg.port,
|
|
108
|
+
database: cfg.database,
|
|
109
|
+
encrypt: cfg.encrypt,
|
|
110
|
+
trustServerCertificate: cfg.trustServerCertificate,
|
|
111
|
+
rowCollectionOnRequestCompletion: true,
|
|
112
|
+
requestTimeout: 120000,
|
|
113
|
+
connectTimeout: 20000,
|
|
114
|
+
validateBulkLoadParameters: false,
|
|
115
|
+
},
|
|
116
|
+
authentication: { type: 'default', options: { userName: cfg.user, password: cfg.password } },
|
|
117
|
+
});
|
|
118
|
+
const open = new Promise((resolve, reject) => {
|
|
119
|
+
connection.on('error', (e) => reject(e));
|
|
120
|
+
connection.connect((err) => (err ? reject(err) : resolve()));
|
|
121
|
+
});
|
|
122
|
+
await open;
|
|
123
|
+
const query = (sql) => new Promise((resolve, reject) => {
|
|
124
|
+
const request = new tedious.Request(sql, (err, _count, rows) => (err ? reject(err) : resolve(rows.map((r) => Object.fromEntries(r.map((c) => [c.metadata.colName, c.value]))))));
|
|
125
|
+
connection.execSqlBatch(request);
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
source,
|
|
129
|
+
target: target ?? `${cfg.server}/${cfg.database}`,
|
|
130
|
+
quote: (name) => `[${String(name).replaceAll(']', ']]')}]`,
|
|
131
|
+
query,
|
|
132
|
+
tableColumns: async (tables) => {
|
|
133
|
+
const map = new Map();
|
|
134
|
+
if (!tables.length)
|
|
135
|
+
return map;
|
|
136
|
+
const list = tables.map((t) => `'${t.replaceAll("'", "''")}'`).join(',');
|
|
137
|
+
for (const r of await query(`SELECT TABLE_NAME AS t, COLUMN_NAME AS c FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME IN (${list})`)) {
|
|
138
|
+
const key = lower(r.t);
|
|
139
|
+
if (!map.has(key))
|
|
140
|
+
map.set(key, new Set());
|
|
141
|
+
map.get(key).add(lower(r.c));
|
|
142
|
+
}
|
|
143
|
+
return map;
|
|
144
|
+
},
|
|
145
|
+
// the caller passes literal identifiers here, never input
|
|
146
|
+
tablesWithColumn: async (column) => new Set((await query(`SELECT TABLE_NAME AS t FROM INFORMATION_SCHEMA.COLUMNS WHERE COLUMN_NAME = '${column.replaceAll("'", "''")}'`))
|
|
147
|
+
.map((r) => lower(r.t))),
|
|
148
|
+
close: async () => connection.close(),
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
// ------------------------------------------------------------------ audit ---
|
|
152
|
+
/**
|
|
153
|
+
* Run every contract check against one adapter. Returns the report (the same
|
|
154
|
+
* shape the production dry-run produced) and the discrepancy list.
|
|
155
|
+
*/
|
|
156
|
+
export async function runOwnerAudit({ adapter, contract = REPO_CONTRACT, log = () => { } }) {
|
|
157
|
+
const { spec, observed, ownership } = contract;
|
|
158
|
+
const T = (name) => adapter.quote(name);
|
|
159
|
+
const discrepancies = [];
|
|
160
|
+
const report = { _task: 'task:3815', _source: adapter.source, _target: adapter.target, clusters: {} };
|
|
161
|
+
// --- declared owner targets: (table, pk) pairs the database really has ---
|
|
162
|
+
const pairs = Object.values(spec.observedOwners).filter((e) => e.table && e.pk).map((e) => `${e.table}.${e.pk}`);
|
|
163
|
+
const aliasPairs = Object.values(spec.aliases).filter((a) => a.table && a.pk).map((a) => `${a.table}.${a.pk}`);
|
|
164
|
+
const allPairs = [...new Set([...pairs, ...aliasPairs])];
|
|
165
|
+
const declaredTables = [...new Set(allPairs.map((p) => p.slice(0, p.lastIndexOf('.'))))];
|
|
166
|
+
const columnsOf = await adapter.tableColumns(declaredTables);
|
|
167
|
+
const live = new Set(allPairs.filter((p) => {
|
|
168
|
+
const i = p.lastIndexOf('.');
|
|
169
|
+
return columnsOf.get(lower(p.slice(0, i)))?.has(lower(p.slice(i + 1)));
|
|
170
|
+
}).map(lower));
|
|
171
|
+
const liveSite = await adapter.tablesWithColumn('SiteId');
|
|
172
|
+
const quarantineTables = Object.values(spec.observedOwners)
|
|
173
|
+
.filter((e) => e.status === 'quarantine-only').map((e) => `${e.table}.${e.pk}`);
|
|
174
|
+
const missingTargets = allPairs.filter((p) => !live.has(lower(p)));
|
|
175
|
+
report.ownerTargets = { declared: allPairs.length, live: live.size, missingInDb: missingTargets };
|
|
176
|
+
log(`[targets] declared=${allPairs.length} live=${live.size} missing=${missingTargets.join(', ') || 'none'}`);
|
|
177
|
+
for (const p of missingTargets) {
|
|
178
|
+
if (!quarantineTables.includes(p)) {
|
|
179
|
+
discrepancies.push(`owner target ${p} is missing from the database but is not declared quarantine-only`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// --- static: every resolvable owner either carries SiteId or has a path ---
|
|
183
|
+
const finalByTable = new Map(ownership.final.map((f) => [f.table, f]));
|
|
184
|
+
report.paths = [];
|
|
185
|
+
for (const [name, entry] of Object.entries(spec.observedOwners)) {
|
|
186
|
+
if (entry.status !== 'resolvable')
|
|
187
|
+
continue;
|
|
188
|
+
if (liveSite.has(lower(entry.table))) {
|
|
189
|
+
report.paths.push({ owner: name, table: entry.table, site: 'direct' });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const path = finalByTable.get(entry.table);
|
|
193
|
+
report.paths.push({ owner: name, table: entry.table, site: path?.path ? `via ${path.path}` : 'MISSING' });
|
|
194
|
+
if (!path?.path)
|
|
195
|
+
discrepancies.push(`owner ${name} (${entry.table}) has no SiteId and no path to Sites in the ownership map`);
|
|
196
|
+
}
|
|
197
|
+
// --- Gallery: the driver of every observed OwnerName value ---------------
|
|
198
|
+
const galleryTargets = Object.entries(spec.observedOwners)
|
|
199
|
+
.filter(([, e]) => e.status === 'resolvable' && live.has(lower(`${e.table}.${e.pk}`)))
|
|
200
|
+
.map(([name, e]) => ({ name, table: e.table, pk: e.pk }));
|
|
201
|
+
// the legacy write path also stores alias targets (ProductVariant, BlogTag, …):
|
|
202
|
+
// an IdOwner pointing at one of those is resolved, not an orphan
|
|
203
|
+
const aliasTargets = Object.entries(spec.aliases)
|
|
204
|
+
.filter(([, a]) => a.table && a.pk && live.has(lower(`${a.table}.${a.pk}`)))
|
|
205
|
+
.map(([name, a]) => ({ name, table: a.table, pk: a.pk }));
|
|
206
|
+
const unionTargets = [...new Map([...galleryTargets, ...aliasTargets].map((t) => [`${t.table}.${t.pk}`, t])).values()];
|
|
207
|
+
const notExists = unionTargets
|
|
208
|
+
.map((t) => `NOT EXISTS (SELECT 1 FROM ${T(t.table)} o WHERE o.${T(t.pk)} = g.IdOwner)`).join(' AND ');
|
|
209
|
+
const orphanWhere = `g.IdOwner IS NOT NULL AND ${notExists}`;
|
|
210
|
+
const gallery = (await adapter.query(`SELECT COUNT(*) AS total, SUM(CASE WHEN IdOwner IS NULL THEN 1 ELSE 0 END) AS noOwner FROM ${T('Gallery')}`))[0];
|
|
211
|
+
const orphanRows = await adapter.query(`SELECT g.OwnerName AS OwnerName, COUNT(*) AS c FROM ${T('Gallery')} g WHERE ${orphanWhere} GROUP BY g.OwnerName ORDER BY COUNT(*) DESC, g.OwnerName ASC`);
|
|
212
|
+
const orphans = orphanRows.reduce((sum, r) => sum + num(r.c), 0);
|
|
213
|
+
report.clusters.gallery = {
|
|
214
|
+
total: num(gallery.total),
|
|
215
|
+
idOwnerNull: num(gallery.noOwner),
|
|
216
|
+
orphans,
|
|
217
|
+
resolved: num(gallery.total) - orphans,
|
|
218
|
+
unionTargets: unionTargets.map((t) => `${t.name}:${t.table}.${t.pk}`),
|
|
219
|
+
orphansByOwnerName: Object.fromEntries(orphanRows.map((r) => [String(r.OwnerName), num(r.c)])),
|
|
220
|
+
};
|
|
221
|
+
log(`[gallery] total=${report.clusters.gallery.total} resolved=${report.clusters.gallery.resolved} quarantine=${orphans} idOwnerNull=${num(gallery.noOwner)}`);
|
|
222
|
+
log('[gallery quarantine by OwnerName] ' + Object.entries(report.clusters.gallery.orphansByOwnerName).map(([k, v]) => `${k}=${v}`).join(' '));
|
|
223
|
+
if (num(gallery.noOwner) !== spec.observedTotals.idOwnerNull) {
|
|
224
|
+
discrepancies.push(`Gallery.IdOwner IS NULL = ${num(gallery.noOwner)}, contract says ${spec.observedTotals.idOwnerNull}`);
|
|
225
|
+
}
|
|
226
|
+
if (orphans !== spec.observedTotals.orphans) {
|
|
227
|
+
discrepancies.push(`Gallery quarantine = ${orphans}, contract says ${spec.observedTotals.orphans}`);
|
|
228
|
+
}
|
|
229
|
+
for (const [name, expected] of Object.entries(observed.gallery.orphansByOwnerName)) {
|
|
230
|
+
const got = report.clusters.gallery.orphansByOwnerName[name] ?? 0;
|
|
231
|
+
if (got !== expected)
|
|
232
|
+
discrepancies.push(`quarantine by OwnerName: ${name} = ${got}, snapshot says ${expected}`);
|
|
233
|
+
}
|
|
234
|
+
// --- cascade of the quarantined galleries (row counts, not galleries) ----
|
|
235
|
+
const cascade = [
|
|
236
|
+
['GalleryImage', `EXISTS (SELECT 1 FROM ${T('Gallery')} g WHERE g.IdGallery = t.IdGallery AND ${orphanWhere})`],
|
|
237
|
+
['GalleryImageSize', `EXISTS (SELECT 1 FROM ${T('Gallery')} g WHERE g.IdGallery = t.IdGallery AND ${orphanWhere})`],
|
|
238
|
+
['GalleryImageFile', `EXISTS (SELECT 1 FROM ${T('GalleryImage')} i WHERE i.IdImage = t.IdImage AND EXISTS (SELECT 1 FROM ${T('Gallery')} g WHERE g.IdGallery = i.IdGallery AND ${orphanWhere}))`],
|
|
239
|
+
['GalleryImageSizeWatermark', `EXISTS (SELECT 1 FROM ${T('GalleryImageSize')} s WHERE s.IdSize = t.GalleryImageSize_IdSize AND EXISTS (SELECT 1 FROM ${T('Gallery')} g WHERE g.IdGallery = s.IdGallery AND ${orphanWhere}))`],
|
|
240
|
+
['Watermark', `EXISTS (SELECT 1 FROM ${T('Gallery')} g WHERE g.IdGallery = t.IdGallery AND ${orphanWhere})`],
|
|
241
|
+
];
|
|
242
|
+
report.clusters.quarantineCascade = {};
|
|
243
|
+
for (const [table, condition] of cascade) {
|
|
244
|
+
const rows = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T(table)} t WHERE ${condition}`))[0].c);
|
|
245
|
+
report.clusters.quarantineCascade[table] = rows;
|
|
246
|
+
const expected = spec.unresolved.cascadeRows[table];
|
|
247
|
+
if (expected != null && rows !== expected)
|
|
248
|
+
discrepancies.push(`quarantine cascade ${table} = ${rows}, contract says ${expected}`);
|
|
249
|
+
}
|
|
250
|
+
log('[quarantine cascade] ' + Object.entries(report.clusters.quarantineCascade).map(([k, v]) => `${k}=${v}`).join(' '));
|
|
251
|
+
// --- rows with no site are orphans only (task:3891, retargeted by task:4092) ---
|
|
252
|
+
// The migration no longer parks anything: an unplaced row keeps `SiteId` NULL and the cutover
|
|
253
|
+
// refuses by name. The invariant that catches a wrong plan ORDER is unchanged — a child
|
|
254
|
+
// backfilled from a parent planted later ends the run with a NULL key whose owner resolves —
|
|
255
|
+
// and it exists only where the tenant key does: a pre-migration source has nothing to
|
|
256
|
+
// misattribute, so the section is absent rather than a pile of zeroes nobody measured.
|
|
257
|
+
//
|
|
258
|
+
// The predicate is the RESOLVER's, not the orphan-cascade count's: a table whose site can
|
|
259
|
+
// come from either of two parents (`GalleryImageSizeWatermark` hangs off a gallery image
|
|
260
|
+
// size AND off the watermark) is misattributed only when a leg that resolves to a LIVE
|
|
261
|
+
// owner exists. Reusing the inverse of the cascade predicate would both miss rows whose
|
|
262
|
+
// gallery has no owner and flag the rows a fallback leg legitimately filled.
|
|
263
|
+
const keylessRows = 't.SiteId IS NULL';
|
|
264
|
+
const notExistsOwners = (alias) => unionTargets
|
|
265
|
+
.map((t) => `NOT EXISTS (SELECT 1 FROM ${T(t.table)} o WHERE o.${T(t.pk)} = ${alias}.IdOwner)`).join(' AND ');
|
|
266
|
+
const liveGallery = (alias) => `${alias}.IdOwner IS NOT NULL AND NOT (${notExistsOwners(alias)})`;
|
|
267
|
+
const viaGallery = (column, alias) => `EXISTS (SELECT 1 FROM ${T('Gallery')} ${alias} WHERE ${alias}.IdGallery = t.${column} AND ${liveGallery(alias)})`;
|
|
268
|
+
const liveOwner = [
|
|
269
|
+
['Gallery', liveGallery('t')],
|
|
270
|
+
['GalleryImage', viaGallery('IdGallery', 'lg1')],
|
|
271
|
+
['GalleryImageSize', viaGallery('IdGallery', 'lg2')],
|
|
272
|
+
['Watermark', viaGallery('IdGallery', 'lg3')],
|
|
273
|
+
['GalleryImageFile', `EXISTS (SELECT 1 FROM ${T('GalleryImage')} i JOIN ${T('Gallery')} lg4 ON lg4.IdGallery = i.IdGallery WHERE i.IdImage = t.IdImage AND ${liveGallery('lg4')})`],
|
|
274
|
+
['GalleryImageSizeWatermark', `EXISTS (SELECT 1 FROM ${T('GalleryImageSize')} s JOIN ${T('Gallery')} lg5 ON lg5.IdGallery = s.IdGallery WHERE s.IdSize = t.GalleryImageSize_IdSize AND ${liveGallery('lg5')})
|
|
275
|
+
OR EXISTS (SELECT 1 FROM ${T('Watermark')} w JOIN ${T('Gallery')} lg6 ON lg6.IdGallery = w.IdGallery WHERE w.IdWatermark = t.Watermark_IdWatermark AND ${liveGallery('lg6')})`],
|
|
276
|
+
].filter(([table]) => liveSite.has(lower(table)));
|
|
277
|
+
if (liveOwner.length) {
|
|
278
|
+
report.clusters.siteLessLiveOwner = {};
|
|
279
|
+
for (const [table, condition] of liveOwner) {
|
|
280
|
+
const rows = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T(table)} t WHERE ${keylessRows} AND (${condition})`))[0].c);
|
|
281
|
+
if (rows === 0)
|
|
282
|
+
continue;
|
|
283
|
+
report.clusters.siteLessLiveOwner[table] = rows;
|
|
284
|
+
discrepancies.push(`row without a site holds a live owner: ${table} = ${rows} rows with a NULL SiteId whose owner resolves`);
|
|
285
|
+
}
|
|
286
|
+
log('[row without a site holds a live owner] ' + (Object.keys(report.clusters.siteLessLiveOwner).length
|
|
287
|
+
? JSON.stringify(report.clusters.siteLessLiveOwner) : 'none'));
|
|
288
|
+
}
|
|
289
|
+
// --- GalleryImageFile hangs off GalleryImage (no direct gallery FK) ------
|
|
290
|
+
const broken = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('GalleryImageFile')} f WHERE NOT EXISTS (SELECT 1 FROM ${T('GalleryImage')} i WHERE i.IdImage = f.IdImage)`))[0].c);
|
|
291
|
+
report.clusters.galleryImageFile = { brokenLinkToImage: broken };
|
|
292
|
+
if (broken !== 0)
|
|
293
|
+
discrepancies.push(`GalleryImageFile rows without a GalleryImage parent: ${broken}`);
|
|
294
|
+
// --- Modules: the host is a real FK, not an OwnerName -------------------
|
|
295
|
+
const moduleColumns = (await adapter.tableColumns(['Modules'])).get('modules') ?? new Set();
|
|
296
|
+
const hostColumns = ['PostId', 'PageId'].filter((c) => moduleColumns.has(lower(c)));
|
|
297
|
+
const modTotal = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('Modules')}`))[0].c);
|
|
298
|
+
const modNoHost = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('Modules')} WHERE PostId IS NULL AND PageId IS NULL`))[0].c);
|
|
299
|
+
const modOrphanPost = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('Modules')} m WHERE m.PostId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${T('Posts')} p WHERE p.PostId = m.PostId)`))[0].c);
|
|
300
|
+
const modOrphanPage = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('Modules')} m WHERE m.PageId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${T('Pages')} p WHERE p.PageId = m.PageId)`))[0].c);
|
|
301
|
+
report.clusters.modules = {
|
|
302
|
+
hostColumns,
|
|
303
|
+
total: modTotal,
|
|
304
|
+
noHost: modNoHost,
|
|
305
|
+
orphanPost: modOrphanPost,
|
|
306
|
+
orphanPage: modOrphanPage,
|
|
307
|
+
resolved: modTotal - modNoHost - modOrphanPost - modOrphanPage,
|
|
308
|
+
};
|
|
309
|
+
log(`[modules] total=${modTotal} resolved=${report.clusters.modules.resolved} noHost=${modNoHost} orphanPost=${modOrphanPost} orphanPage=${modOrphanPage}`);
|
|
310
|
+
if (modNoHost !== 0)
|
|
311
|
+
discrepancies.push(`Modules without a host (PostId/PageId both NULL): ${modNoHost}`);
|
|
312
|
+
if (modOrphanPost !== 0 || modOrphanPage !== 0)
|
|
313
|
+
discrepancies.push(`Modules with a dangling host FK: post=${modOrphanPost} page=${modOrphanPage}`);
|
|
314
|
+
// --- Numbering: counters resolve through OwnerId; tasks are the seed ----
|
|
315
|
+
const nt = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('NumberingTasks')}`))[0].c);
|
|
316
|
+
const ncTotal = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('NumberingCounters')}`))[0].c);
|
|
317
|
+
const ncDistinct = num((await adapter.query(`SELECT COUNT(DISTINCT OwnerId) AS c FROM ${T('NumberingCounters')}`))[0].c);
|
|
318
|
+
const ncNoOwner = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('NumberingCounters')} WHERE OwnerId IS NULL`))[0].c);
|
|
319
|
+
const ncQuarantine = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('NumberingCounters')} c WHERE c.OwnerId IS NOT NULL
|
|
320
|
+
AND NOT EXISTS (SELECT 1 FROM ${T('Blogs')} b WHERE b.BlogId = c.OwnerId)
|
|
321
|
+
AND NOT EXISTS (SELECT 1 FROM ${T('Stores')} s WHERE s.StoreId = c.OwnerId)
|
|
322
|
+
AND NOT EXISTS (SELECT 1 FROM ${T('Catalogs')} k WHERE k.CatalogId = c.OwnerId)
|
|
323
|
+
AND NOT EXISTS (SELECT 1 FROM ${T('Sites')} s WHERE s.SiteId = c.OwnerId)`))[0].c);
|
|
324
|
+
report.clusters.numbering = { tasks: nt, counters: ncTotal, distinctOwners: ncDistinct, counterNoOwner: ncNoOwner, counterQuarantine: ncQuarantine };
|
|
325
|
+
log(`[numbering] tasks=${nt} counters=${ncTotal} distinctOwners=${ncDistinct} quarantine=${ncQuarantine}`);
|
|
326
|
+
if (nt !== spec.numbering.tasks.seed)
|
|
327
|
+
discrepancies.push(`NumberingTasks = ${nt}, contract seed says ${spec.numbering.tasks.seed}`);
|
|
328
|
+
if (ncTotal !== spec.numbering.counters.rows)
|
|
329
|
+
discrepancies.push(`NumberingCounters = ${ncTotal}, contract says ${spec.numbering.counters.rows}`);
|
|
330
|
+
if (ncDistinct !== spec.numbering.counters.distinctOwners)
|
|
331
|
+
discrepancies.push(`distinct NumberingCounters.OwnerId = ${ncDistinct}, contract says ${spec.numbering.counters.distinctOwners}`);
|
|
332
|
+
if (ncNoOwner !== 0)
|
|
333
|
+
discrepancies.push(`NumberingCounters without OwnerId: ${ncNoOwner}`);
|
|
334
|
+
// --- dead-template dictionaries: nothing to resolve ---------------------
|
|
335
|
+
const git = num((await adapter.query(`SELECT COUNT(*) AS c FROM ${T('GalleryImagesTemplates')}`))[0].c);
|
|
336
|
+
report.clusters.galleryImagesTemplates = { rows: git };
|
|
337
|
+
if (git !== observed.emptyMaps.GalleryImagesTemplates) {
|
|
338
|
+
discrepancies.push(`GalleryImagesTemplates = ${git}, snapshot says ${observed.emptyMaps.GalleryImagesTemplates}`);
|
|
339
|
+
}
|
|
340
|
+
report.discrepancies = discrepancies;
|
|
341
|
+
return { report, discrepancies };
|
|
342
|
+
}
|
|
343
|
+
/** Compare a fresh report with a committed one, ignoring where it ran. */
|
|
344
|
+
export const reportDiff = (fresh, expected) => {
|
|
345
|
+
const strip = ({ _source, _target, ...rest }) => rest;
|
|
346
|
+
const diff = [];
|
|
347
|
+
const walk = (a, b, path) => {
|
|
348
|
+
const keys = [...new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])];
|
|
349
|
+
for (const key of keys) {
|
|
350
|
+
const va = a?.[key];
|
|
351
|
+
const vb = b?.[key];
|
|
352
|
+
const at = path ? `${path}.${key}` : key;
|
|
353
|
+
if (va && vb && typeof va === 'object' && typeof vb === 'object')
|
|
354
|
+
walk(va, vb, at);
|
|
355
|
+
else if (JSON.stringify(va) !== JSON.stringify(vb))
|
|
356
|
+
diff.push(`${at}: fresh=${JSON.stringify(va)} expected=${JSON.stringify(vb)}`);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
walk(strip(fresh), strip(expected), '');
|
|
360
|
+
return diff;
|
|
361
|
+
};
|
|
362
|
+
// -------------------------------------------------------------------- CLI ---
|
|
363
|
+
const args = process.argv.slice(2);
|
|
364
|
+
const flag = (name, fallback = undefined) => {
|
|
365
|
+
const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
366
|
+
if (!hit)
|
|
367
|
+
return fallback;
|
|
368
|
+
return hit.includes('=') ? hit.slice(hit.indexOf('=') + 1) : true;
|
|
369
|
+
};
|
|
370
|
+
const source = flag('source', 'fixture');
|
|
371
|
+
const noCompare = args.includes('--no-compare');
|
|
372
|
+
const reportPath = flag('report');
|
|
373
|
+
const compareFlag = flag('compare');
|
|
374
|
+
// the fixture is pinned to the committed production report by default: a green
|
|
375
|
+
// fixture must mean "the contract still matches what production looked like".
|
|
376
|
+
const comparePath = compareFlag
|
|
377
|
+
? (compareFlag === true ? PROD_EVIDENCE : compareFlag)
|
|
378
|
+
: (source === 'fixture' && !noCompare ? PROD_EVIDENCE : null);
|
|
379
|
+
const main = async () => {
|
|
380
|
+
let adapter;
|
|
381
|
+
let fixture;
|
|
382
|
+
if (source === 'fixture') {
|
|
383
|
+
fixture = createOwnersFixture();
|
|
384
|
+
adapter = sqliteAdapter(fixture.db, {
|
|
385
|
+
source: 'fixture: packages/data/scripts/owners-fixture.mjs (synthetic, derived from the contract)',
|
|
386
|
+
target: `${fixture.tableCount} generated tables`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
else if (source === 'mssql') {
|
|
390
|
+
const url = process.env.MSSQL_AUDIT_URL;
|
|
391
|
+
if (!url) {
|
|
392
|
+
console.error('MSSQL_AUDIT_URL is not set — the connection string is a secret-ref (canon/gates Г6), not a repo file.');
|
|
393
|
+
process.exit(2);
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
adapter = await mssqlAdapter({ url });
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
console.error(`[connect] ${e.message}`);
|
|
400
|
+
process.exit(2);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
console.error(`unknown --source=${source} (fixture | mssql)`);
|
|
405
|
+
process.exit(2);
|
|
406
|
+
}
|
|
407
|
+
const { report, discrepancies } = await runOwnerAudit({ adapter, log: (m) => console.log(m) });
|
|
408
|
+
await adapter.close();
|
|
409
|
+
if (reportPath) {
|
|
410
|
+
fs.writeFileSync(reportPath === true ? 'owners-audit-report.json' : reportPath, JSON.stringify(report, null, 2) + '\n');
|
|
411
|
+
console.log(`report: ${reportPath}`);
|
|
412
|
+
}
|
|
413
|
+
if (comparePath) {
|
|
414
|
+
let expected;
|
|
415
|
+
try {
|
|
416
|
+
expected = JSON.parse(fs.readFileSync(comparePath, 'utf8'));
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
console.error(`cannot read the comparison report ${comparePath}: ${e.message}`);
|
|
420
|
+
process.exit(2);
|
|
421
|
+
}
|
|
422
|
+
const diff = reportDiff(report, expected);
|
|
423
|
+
if (diff.length) {
|
|
424
|
+
console.error(`\nFAIL report drift against ${comparePath}: ${diff.length} field(s) differ`);
|
|
425
|
+
for (const line of diff)
|
|
426
|
+
console.error(' ' + line);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
console.log(`[compare] ${comparePath}: identical (mode/target ignored)`);
|
|
430
|
+
}
|
|
431
|
+
if (discrepancies.length) {
|
|
432
|
+
console.error(`\nFAIL owner audit: ${discrepancies.length} discrepancy(ies) outside the quarantine`);
|
|
433
|
+
for (const d of discrepancies)
|
|
434
|
+
console.error(' - ' + d);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
console.log('\nPASS owner audit: 0 discrepancies outside the quarantine');
|
|
438
|
+
const cascadeRows = Object.values(report.clusters.quarantineCascade).reduce((a, b) => a + b, 0);
|
|
439
|
+
console.log(` quarantined (allowed): Gallery ${report.clusters.gallery.orphans} + cascade ${cascadeRows} rows, NumberingCounters ${report.clusters.numbering.counterQuarantine}`);
|
|
440
|
+
};
|
|
441
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) {
|
|
442
|
+
await main();
|
|
443
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the fixture database. Returns the live `DatabaseSync` handle plus the
|
|
3
|
+
* resolved contract, so a caller can drive the same audit the CLI does.
|
|
4
|
+
*/
|
|
5
|
+
export function createOwnersFixture(): {
|
|
6
|
+
db: DatabaseSync;
|
|
7
|
+
contract: {
|
|
8
|
+
spec: any;
|
|
9
|
+
observed: any;
|
|
10
|
+
ownership: any;
|
|
11
|
+
};
|
|
12
|
+
tableCount: number;
|
|
13
|
+
};
|
|
14
|
+
export function readContract(file: any): any;
|
|
15
|
+
export function loadContract(): {
|
|
16
|
+
spec: any;
|
|
17
|
+
observed: any;
|
|
18
|
+
ownership: any;
|
|
19
|
+
};
|
|
20
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// A synthetic, deterministic fixture for the R1 owner-resolution audit
|
|
2
|
+
// (task:3863, the verifier of task:3815).
|
|
3
|
+
//
|
|
4
|
+
// The audit used to run only against the production MSSQL server, which made
|
|
5
|
+
// its central proof (0 discrepancies outside the quarantine) impossible to
|
|
6
|
+
// reproduce: no scripts, no inputs and no report lived in the repository. The
|
|
7
|
+
// fixture below closes that gap — it builds an in-memory SQLite database whose
|
|
8
|
+
// *shape* is derived from the contract itself (`owner-resolve-spec.json`), the
|
|
9
|
+
// frozen production snapshot (`owners-observed.json`) and the ownership map
|
|
10
|
+
// (`ownership-final.json`), so the checker can be exercised anywhere:
|
|
11
|
+
//
|
|
12
|
+
// node packages/data/scripts/owners-audit.mjs --source=fixture --expect-zero
|
|
13
|
+
//
|
|
14
|
+
// What the fixture proves: the audit *runs deterministically from the repo* and
|
|
15
|
+
// its checks hold on data that satisfies the contract. What it does NOT prove:
|
|
16
|
+
// anything about production. That evidence is the committed report
|
|
17
|
+
// `owners/prod-audit-report.json` plus a re-run in `--source=mssql` mode against
|
|
18
|
+
// the verified local copy of production (wiki:concepts/db-local-prod-copy).
|
|
19
|
+
//
|
|
20
|
+
// Every number here is derived from the contract files, never hardcoded: if the
|
|
21
|
+
// contract says "638 orphans, cascade 297/1048/295/17/14", the generator creates
|
|
22
|
+
// exactly that, and the audit independently re-measures it.
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
const CONTRACT_DIR = new URL('./owners/', import.meta.url);
|
|
27
|
+
export const readContract = (file) => JSON.parse(fs.readFileSync(fileURLToPath(new URL(file, CONTRACT_DIR)), 'utf8'));
|
|
28
|
+
export const loadContract = () => ({
|
|
29
|
+
spec: readContract('owner-resolve-spec.json'),
|
|
30
|
+
observed: readContract('owners-observed.json'),
|
|
31
|
+
ownership: readContract('ownership-final.json'),
|
|
32
|
+
});
|
|
33
|
+
const ident = (name) => `"${String(name).replaceAll('"', '""')}"`;
|
|
34
|
+
/**
|
|
35
|
+
* Build the fixture database. Returns the live `DatabaseSync` handle plus the
|
|
36
|
+
* resolved contract, so a caller can drive the same audit the CLI does.
|
|
37
|
+
*/
|
|
38
|
+
export function createOwnersFixture() {
|
|
39
|
+
const contract = loadContract();
|
|
40
|
+
const { spec, observed, ownership } = contract;
|
|
41
|
+
const db = new DatabaseSync(':memory:');
|
|
42
|
+
const created = new Set();
|
|
43
|
+
const createTable = (table, columns) => {
|
|
44
|
+
if (created.has(table))
|
|
45
|
+
return;
|
|
46
|
+
created.add(table);
|
|
47
|
+
db.exec(`CREATE TABLE ${ident(table)} (${columns.map(ident).join(', ')})`);
|
|
48
|
+
};
|
|
49
|
+
const insert = (table, columns, rows) => {
|
|
50
|
+
if (!rows.length)
|
|
51
|
+
return;
|
|
52
|
+
const stmt = db.prepare(`INSERT INTO ${ident(table)} (${columns.map(ident).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`);
|
|
53
|
+
for (const row of rows)
|
|
54
|
+
stmt.run(...columns.map((c) => row[c]));
|
|
55
|
+
};
|
|
56
|
+
const rowsFor = (table, columns, values, map) => {
|
|
57
|
+
createTable(table, columns);
|
|
58
|
+
insert(table, columns, values.map(map));
|
|
59
|
+
};
|
|
60
|
+
// ---- owner targets: one table per declared (table, pk), then the rows that
|
|
61
|
+
// the resolved galleries will point at.
|
|
62
|
+
const siteColOf = new Map(ownership.final.map((f) => [f.table, f.siteCol]));
|
|
63
|
+
const declaredPairs = new Map(); // table -> pk
|
|
64
|
+
for (const entry of Object.values(spec.observedOwners)) {
|
|
65
|
+
if (entry.table && entry.pk)
|
|
66
|
+
declaredPairs.set(entry.table, entry.pk);
|
|
67
|
+
}
|
|
68
|
+
for (const alias of Object.values(spec.aliases)) {
|
|
69
|
+
if (alias.table && alias.pk && !declaredPairs.has(alias.table))
|
|
70
|
+
declaredPairs.set(alias.table, alias.pk);
|
|
71
|
+
}
|
|
72
|
+
const polyRoots = spec.polyRoots ?? {};
|
|
73
|
+
const targets = {}; // owner name -> { table, pk, ids: string[] }
|
|
74
|
+
const galleries = [];
|
|
75
|
+
const quarantineGalleries = [];
|
|
76
|
+
for (const [name, entry] of Object.entries(spec.observedOwners)) {
|
|
77
|
+
if (!entry.table || !entry.pk)
|
|
78
|
+
throw new Error(`fixture: ${name} has no (table, pk) target`);
|
|
79
|
+
const total = observed.gallery.byOwnerName?.[name] ?? 0;
|
|
80
|
+
const orphans = observed.gallery.orphansByOwnerName?.[name] ?? 0;
|
|
81
|
+
const resolved = total - orphans;
|
|
82
|
+
if (resolved < 0)
|
|
83
|
+
throw new Error(`fixture: ${name} has more orphans (${orphans}) than rows (${total})`);
|
|
84
|
+
const columns = [entry.pk];
|
|
85
|
+
if (polyRoots[entry.table]?.hostColumns)
|
|
86
|
+
columns.push(...polyRoots[entry.table].hostColumns);
|
|
87
|
+
else if (siteColOf.get(entry.table) === 'SiteId')
|
|
88
|
+
columns.push('SiteId');
|
|
89
|
+
createTable(entry.table, columns);
|
|
90
|
+
// Modules is a poly-root: its host is a real FK to Posts (or Pages), so
|
|
91
|
+
// every module row must point at a host row the fixture also creates.
|
|
92
|
+
const hostOf = polyRoots[entry.table]?.hostTables ?? {};
|
|
93
|
+
const ids = [];
|
|
94
|
+
for (let i = 1; i <= Math.max(resolved, 0); i += 1)
|
|
95
|
+
ids.push(`${entry.table}#${i}`);
|
|
96
|
+
const rowCount = Math.max(resolved, entry.status === 'resolvable' && entry.table === 'Modules'
|
|
97
|
+
? (polyRoots.Modules.evidence?.total ?? resolved) : resolved);
|
|
98
|
+
const rows = [];
|
|
99
|
+
for (let i = 0; i < rowCount; i += 1) {
|
|
100
|
+
const row = { [entry.pk]: ids[i] ?? `${entry.table}#${i + 1}` };
|
|
101
|
+
for (const [column, hostTable] of Object.entries(hostOf)) {
|
|
102
|
+
row[column] = hostTable === 'Posts' ? 'Posts#1' : null;
|
|
103
|
+
}
|
|
104
|
+
if (columns.includes('SiteId'))
|
|
105
|
+
row.SiteId = 'Sites#1';
|
|
106
|
+
rows.push(row);
|
|
107
|
+
if (!ids.includes(row[entry.pk]))
|
|
108
|
+
ids.push(row[entry.pk]);
|
|
109
|
+
}
|
|
110
|
+
insert(entry.table, columns, rows);
|
|
111
|
+
targets[name] = { table: entry.table, pk: entry.pk, ids };
|
|
112
|
+
for (let i = 0; i < resolved; i += 1) {
|
|
113
|
+
galleries.push({ IdGallery: `Gallery#${galleries.length + 1}`, IdOwner: ids[i], OwnerName: name });
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i < orphans; i += 1) {
|
|
116
|
+
const g = { IdGallery: `Gallery#${galleries.length + 1}`, IdOwner: `orphan:${name}:${i + 1}`, OwnerName: name };
|
|
117
|
+
galleries.push(g);
|
|
118
|
+
quarantineGalleries.push(g);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Declared but never observed (aliases with a table, the dropped Parts): the
|
|
122
|
+
// tables must still exist, empty, or the "declared target is missing" check
|
|
123
|
+
// fires for a reason the contract did not intend.
|
|
124
|
+
for (const [table, pk] of declaredPairs)
|
|
125
|
+
createTable(table, [pk]);
|
|
126
|
+
// ---- poly-root hosts need a row the modules can point at
|
|
127
|
+
if (polyRoots.Modules)
|
|
128
|
+
createTable('Pages', ['PageId', 'SiteId']);
|
|
129
|
+
// ---- numbering: the seed rows and the counters, all resolvable through
|
|
130
|
+
// owner rows (the contract says quarantine is measured, not allowed).
|
|
131
|
+
const numbering = spec.numbering ?? {};
|
|
132
|
+
const taskSeed = numbering.tasks?.seed ?? 0;
|
|
133
|
+
createTable('NumberingTasks', ['TaskId', 'Label']);
|
|
134
|
+
insert('NumberingTasks', ['TaskId', 'Label'], Array.from({ length: taskSeed }, (_, i) => ({ TaskId: `NumberingTasks#${i + 1}`, Label: numbering.tasks?.labels?.[i] ?? `task-${i + 1}` })));
|
|
135
|
+
const counterRows = numbering.counters?.rows ?? 0;
|
|
136
|
+
const distinctOwners = numbering.counters?.distinctOwners ?? counterRows;
|
|
137
|
+
createTable('Sites', ['SiteId']);
|
|
138
|
+
insert('Sites', ['SiteId'], Array.from({ length: distinctOwners }, (_, i) => ({ SiteId: `Sites#${i + 1}` })));
|
|
139
|
+
createTable('NumberingCounters', ['CounterId', 'OwnerId']);
|
|
140
|
+
insert('NumberingCounters', ['CounterId', 'OwnerId'], Array.from({ length: counterRows }, (_, i) => ({
|
|
141
|
+
CounterId: `NumberingCounters#${i + 1}`,
|
|
142
|
+
OwnerId: `Sites#${(i % distinctOwners) + 1}`,
|
|
143
|
+
})));
|
|
144
|
+
// ---- cascade of the quarantined galleries: the child rows stay, that is
|
|
145
|
+
// the operator's verdict, so the fixture reproduces them exactly.
|
|
146
|
+
const cascade = spec.unresolved?.cascadeRows ?? {};
|
|
147
|
+
const orphanIds = quarantineGalleries.map((g) => g.IdGallery);
|
|
148
|
+
const pickOrphan = (i) => orphanIds[i % orphanIds.length];
|
|
149
|
+
for (const table of ['GalleryImage', 'GalleryImageSize', 'GalleryImageFile', 'GalleryImageSizeWatermark', 'Watermark']) {
|
|
150
|
+
const count = cascade[table] ?? 0;
|
|
151
|
+
if (!count)
|
|
152
|
+
continue;
|
|
153
|
+
if (table === 'GalleryImage') {
|
|
154
|
+
createTable(table, ['IdImage', 'IdGallery']);
|
|
155
|
+
insert(table, ['IdImage', 'IdGallery'], Array.from({ length: count }, (_, i) => ({
|
|
156
|
+
IdImage: `GalleryImage#${i + 1}`, IdGallery: pickOrphan(i),
|
|
157
|
+
})));
|
|
158
|
+
}
|
|
159
|
+
else if (table === 'GalleryImageSize') {
|
|
160
|
+
createTable(table, ['IdSize', 'IdGallery']);
|
|
161
|
+
insert(table, ['IdSize', 'IdGallery'], Array.from({ length: count }, (_, i) => ({
|
|
162
|
+
IdSize: `GalleryImageSize#${i + 1}`, IdGallery: pickOrphan(i),
|
|
163
|
+
})));
|
|
164
|
+
}
|
|
165
|
+
else if (table === 'GalleryImageFile') {
|
|
166
|
+
createTable(table, ['IdFile', 'IdImage']);
|
|
167
|
+
insert(table, ['IdFile', 'IdImage'], Array.from({ length: count }, (_, i) => ({
|
|
168
|
+
IdFile: `GalleryImageFile#${i + 1}`, IdImage: `GalleryImage#${(i % (cascade.GalleryImage ?? 1)) + 1}`,
|
|
169
|
+
})));
|
|
170
|
+
}
|
|
171
|
+
else if (table === 'GalleryImageSizeWatermark') {
|
|
172
|
+
createTable(table, ['Id', 'GalleryImageSize_IdSize']);
|
|
173
|
+
insert(table, ['Id', 'GalleryImageSize_IdSize'], Array.from({ length: count }, (_, i) => ({
|
|
174
|
+
Id: `GalleryImageSizeWatermark#${i + 1}`,
|
|
175
|
+
GalleryImageSize_IdSize: `GalleryImageSize#${(i % (cascade.GalleryImageSize ?? 1)) + 1}`,
|
|
176
|
+
})));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
createTable(table, ['IdWatermark', 'IdGallery']);
|
|
180
|
+
insert(table, ['IdWatermark', 'IdGallery'], Array.from({ length: count }, (_, i) => ({
|
|
181
|
+
IdWatermark: `Watermark#${i + 1}`, IdGallery: pickOrphan(i),
|
|
182
|
+
})));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ---- the dead-template dictionary (0 rows on production, and the contract
|
|
186
|
+
// uses it as a "is the audit looking where it says" control).
|
|
187
|
+
createTable('GalleryImagesTemplates', ['IdTemplate']);
|
|
188
|
+
// ---- the poly-root itself
|
|
189
|
+
createTable('Gallery', ['IdGallery', 'IdOwner', 'OwnerName']);
|
|
190
|
+
insert('Gallery', ['IdGallery', 'IdOwner', 'OwnerName'], galleries);
|
|
191
|
+
return { db, contract, tableCount: created.size };
|
|
192
|
+
}
|