@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,25 @@
|
|
|
1
|
+
export namespace LEGACY_SLICE {
|
|
2
|
+
let total: number;
|
|
3
|
+
let site: number;
|
|
4
|
+
}
|
|
5
|
+
export namespace CANON_EXPECTED {
|
|
6
|
+
let total_1: number;
|
|
7
|
+
export { total_1 as total };
|
|
8
|
+
let site_1: number;
|
|
9
|
+
export { site_1 as site };
|
|
10
|
+
export let nonSite: number;
|
|
11
|
+
export let control: number;
|
|
12
|
+
export let registry: number;
|
|
13
|
+
export let dictionary: number;
|
|
14
|
+
export let siteIdAdded: number;
|
|
15
|
+
export let siteExisting: number;
|
|
16
|
+
}
|
|
17
|
+
export const LEGACY_REMOVED_TABLES: string[];
|
|
18
|
+
export function auditCanon({ tables }?: {
|
|
19
|
+
tables?: object[];
|
|
20
|
+
}): {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
problems: string[];
|
|
23
|
+
counts: object;
|
|
24
|
+
legacy: object;
|
|
25
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The canon audit: verifier of task:3864 (finding of the umbrella review,
|
|
2
|
+
// task:3831), and the machine-readable form of the numbers reconciliation.
|
|
3
|
+
//
|
|
4
|
+
// node packages/data/scripts/canon-audit.mjs
|
|
5
|
+
//
|
|
6
|
+
// Why this exists
|
|
7
|
+
// ---------------
|
|
8
|
+
// The decisions (plan:48 Q1, plan:49, plan:50 Q4, requirements:64) took their
|
|
9
|
+
// numbers from the legacy-derived ownership map (wiki:3821 v12), which was
|
|
10
|
+
// built from the 168 EF6 models of the live schema:
|
|
11
|
+
//
|
|
12
|
+
// 121 legacy tables = 112 site-scoped + 9 non-site
|
|
13
|
+
//
|
|
14
|
+
// That is the LEGACY slice. The target canon carries one table FEWER (task:3936,
|
|
15
|
+
// one-schema design wiki:3874 §2):
|
|
16
|
+
//
|
|
17
|
+
// 120 tables = 111 site-scoped + 9 non-site
|
|
18
|
+
//
|
|
19
|
+
// Two tables left the canon after the map was taken:
|
|
20
|
+
// * `AdministratorReplicas` — one schema for every mode means there is nothing to
|
|
21
|
+
// replicate: the administrator row lives in the same database in both modes;
|
|
22
|
+
// * `LogEvents` — dead, nobody writes it.
|
|
23
|
+
// The canon is the authority; the map's 121/112 is a legacy slice, annotated as such
|
|
24
|
+
// in the decisions.
|
|
25
|
+
//
|
|
26
|
+
// Correction (task:3876, operator verdict): the map counted `NumberingTasks` as
|
|
27
|
+
// site-scoped because the EF6 model carried a SiteId that the live table never
|
|
28
|
+
// had — the engine addresses a numbering task by GUID and `NumberingCounters.TaskId`
|
|
29
|
+
// is a hard FK to it, so the table is global config. It moved to the dictionary
|
|
30
|
+
// scope: one table left the site count, so 114/81 became 113/80 while the totals
|
|
31
|
+
// stayed put.
|
|
32
|
+
//
|
|
33
|
+
// This script re-derives every number from the canon and compares it to that
|
|
34
|
+
// annotated expectation, so the reconciliation is reproducible instead of a
|
|
35
|
+
// paragraph that drifts. It also cross-checks the map's composition verdicts
|
|
36
|
+
// (the 47 dropped tables, the control/registry/dictionary split, PK coverage,
|
|
37
|
+
// the tenant key) table by table — not just the totals.
|
|
38
|
+
//
|
|
39
|
+
// Exit code 1 and a named discrepancy list on any mismatch: the audit is the
|
|
40
|
+
// oracle.
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { fileURLToPath } from 'node:url';
|
|
44
|
+
import { canonicalSchema, CANON_TYPES, isSiteScoped } from "../schema/index.js";
|
|
45
|
+
// ------------------------------------------------------- declared expectations ---
|
|
46
|
+
// Numbers as annotated in the decisions after task:3864. Keep the legacy pair in
|
|
47
|
+
// sync with plan:48/49/50 and requirements:64; the canon pair is asserted below.
|
|
48
|
+
export const LEGACY_SLICE = { total: 121, site: 112 };
|
|
49
|
+
export const CANON_EXPECTED = {
|
|
50
|
+
total: 120,
|
|
51
|
+
site: 111,
|
|
52
|
+
nonSite: 9,
|
|
53
|
+
control: 6,
|
|
54
|
+
registry: 1,
|
|
55
|
+
dictionary: 2,
|
|
56
|
+
siteIdAdded: 80,
|
|
57
|
+
siteExisting: 31,
|
|
58
|
+
};
|
|
59
|
+
// Tables the legacy map counted and the canon no longer carries (task:3936) — the
|
|
60
|
+
// reconciliation subtracts them, so a silent re-add fails here by name. The map never
|
|
61
|
+
// had `AdministratorReplicas` (it was the one-table delta over the map), so it is not
|
|
62
|
+
// subtracted: the delta is simply zero now, and a re-add fails this list too.
|
|
63
|
+
export const LEGACY_REMOVED_TABLES = ['LogEvents'];
|
|
64
|
+
// The 47 tables dropped by the operator verdicts (wiki:3821 v12) — frozen here
|
|
65
|
+
// so a resurrection is a named failure rather than a quietly larger schema.
|
|
66
|
+
const DROPPED = [
|
|
67
|
+
'Applications', 'AspNetRoles', 'AspNetUserClaims', 'AspNetUserLogins', 'AspNetUserRoles',
|
|
68
|
+
'AspNetUsers', 'BlogMetaFields', 'BlogMetaTags', 'ContentCacheItems', 'ContentPageMetaTags',
|
|
69
|
+
'ContentPagePartMetaFields', 'Customers', 'ImageCacheItems', 'Memberships', 'MetaFields',
|
|
70
|
+
'MetaTags', 'PageMetaFields', 'PageMetaTags', 'Parts', 'PortfolioCategories',
|
|
71
|
+
'PortfolioCategoryProjects', 'PortfolioMetaFields', 'PortfolioMetaTags', 'PortfolioTagProjects',
|
|
72
|
+
'PortfolioTags', 'Portfolios', 'PostMetaFields', 'PostMetaTags', 'ProductMetaFields',
|
|
73
|
+
'ProductMetaTags', 'ProductVariantMetaFields', 'Profiles', 'ProjectComments', 'ProjectLikes',
|
|
74
|
+
'ProjectMetaFields', 'ProjectMetaTags', 'Projects', 'Roles', 'SearchIndexTasks',
|
|
75
|
+
'SiteMetaFields', 'SiteMetaTags', 'StoreCategoryMetaFields', 'StoreCategoryMetaTags',
|
|
76
|
+
'StoreMetaFields', 'StoreMetaTags', 'Users', 'UsersInRoles',
|
|
77
|
+
];
|
|
78
|
+
const CONTROL = ['Accounts', 'Administrators', 'AdministratorAccounts', 'AdministratorSites', 'AdministratorStores', 'ApiKeys'];
|
|
79
|
+
const REGISTRY = ['Sites'];
|
|
80
|
+
const DICTIONARY = ['InventoryPolicies', 'NumberingTasks'];
|
|
81
|
+
const sorted = (a) => [...a].sort();
|
|
82
|
+
/**
|
|
83
|
+
* Re-derive the canon numbers and compare them to the annotated expectation.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} [override]
|
|
86
|
+
* @param {object[]} [override.tables] table specs to audit (default: the canon)
|
|
87
|
+
* @returns {{ok: boolean, problems: string[], counts: object, legacy: object}}
|
|
88
|
+
*/
|
|
89
|
+
export const auditCanon = ({ tables = canonicalSchema.tables } = {}) => {
|
|
90
|
+
const problems = [];
|
|
91
|
+
const names = tables.map((t) => t.name);
|
|
92
|
+
const site = tables.filter(isSiteScoped);
|
|
93
|
+
const nonSite = tables.filter((t) => !isSiteScoped(t));
|
|
94
|
+
const byScope = (scope) => tables.filter((t) => t.scope === scope);
|
|
95
|
+
const siteIdAdded = site.filter((t) => t.siteIdAdded);
|
|
96
|
+
const counts = {
|
|
97
|
+
total: tables.length,
|
|
98
|
+
site: site.length,
|
|
99
|
+
nonSite: nonSite.length,
|
|
100
|
+
control: byScope('control').length,
|
|
101
|
+
registry: byScope('registry').length,
|
|
102
|
+
dictionary: byScope('dictionary').length,
|
|
103
|
+
siteIdAdded: siteIdAdded.length,
|
|
104
|
+
siteExisting: site.length - siteIdAdded.length,
|
|
105
|
+
};
|
|
106
|
+
// ---- totals and composition (the map's verdicts, in machine form) --------
|
|
107
|
+
if (counts.total !== CANON_EXPECTED.total) {
|
|
108
|
+
problems.push(`table count: expected ${CANON_EXPECTED.total}, found ${counts.total}`);
|
|
109
|
+
}
|
|
110
|
+
if (counts.site !== CANON_EXPECTED.site) {
|
|
111
|
+
problems.push(`site-scoped count: expected ${CANON_EXPECTED.site}, found ${counts.site}`);
|
|
112
|
+
}
|
|
113
|
+
if (counts.nonSite !== CANON_EXPECTED.nonSite) {
|
|
114
|
+
problems.push(`non-site count: expected ${CANON_EXPECTED.nonSite}, found ${counts.nonSite}`);
|
|
115
|
+
}
|
|
116
|
+
if (counts.control !== CANON_EXPECTED.control) {
|
|
117
|
+
problems.push(`control count: expected ${CANON_EXPECTED.control}, found ${counts.control}`);
|
|
118
|
+
}
|
|
119
|
+
if (counts.registry !== CANON_EXPECTED.registry) {
|
|
120
|
+
problems.push(`registry count: expected ${CANON_EXPECTED.registry}, found ${counts.registry}`);
|
|
121
|
+
}
|
|
122
|
+
if (counts.dictionary !== CANON_EXPECTED.dictionary) {
|
|
123
|
+
problems.push(`dictionary count: expected ${CANON_EXPECTED.dictionary}, found ${counts.dictionary}`);
|
|
124
|
+
}
|
|
125
|
+
const expectedNonSite = sorted([...CONTROL, ...REGISTRY, ...DICTIONARY]);
|
|
126
|
+
if (JSON.stringify(sorted(nonSite.map((t) => t.name))) !== JSON.stringify(expectedNonSite)) {
|
|
127
|
+
problems.push(`non-site composition: expected ${expectedNonSite.join(',')}, found ${sorted(nonSite.map((t) => t.name)).join(',')}`);
|
|
128
|
+
}
|
|
129
|
+
if (new Set(names).size !== names.length) {
|
|
130
|
+
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
|
131
|
+
problems.push(`duplicate table names: ${[...new Set(dupes)].join(',')}`);
|
|
132
|
+
}
|
|
133
|
+
// ---- the annotated legacy reconciliation ---------------------------------
|
|
134
|
+
if (counts.total !== LEGACY_SLICE.total - LEGACY_REMOVED_TABLES.length) {
|
|
135
|
+
problems.push(`legacy total: ${LEGACY_SLICE.total} (legacy slice) - ${LEGACY_REMOVED_TABLES.length} (removed) != ${counts.total}`);
|
|
136
|
+
}
|
|
137
|
+
if (counts.site !== LEGACY_SLICE.site - LEGACY_REMOVED_TABLES.length) {
|
|
138
|
+
problems.push(`legacy site: ${LEGACY_SLICE.site} (legacy slice) - ${LEGACY_REMOVED_TABLES.length} (removed) != ${counts.site}`);
|
|
139
|
+
}
|
|
140
|
+
for (const name of LEGACY_REMOVED_TABLES) {
|
|
141
|
+
if (names.includes(name))
|
|
142
|
+
problems.push(`removed table is back in the canon: ${name}`);
|
|
143
|
+
}
|
|
144
|
+
// ---- the 47 dropped stay dropped ----------------------------------------
|
|
145
|
+
for (const name of DROPPED) {
|
|
146
|
+
if (names.includes(name))
|
|
147
|
+
problems.push(`dropped table present: ${name}`);
|
|
148
|
+
}
|
|
149
|
+
// ---- per-table invariants ------------------------------------------------
|
|
150
|
+
if (counts.siteIdAdded !== CANON_EXPECTED.siteIdAdded) {
|
|
151
|
+
problems.push(`siteIdAdded count: expected ${CANON_EXPECTED.siteIdAdded}, found ${counts.siteIdAdded}`);
|
|
152
|
+
}
|
|
153
|
+
if (counts.siteExisting !== CANON_EXPECTED.siteExisting) {
|
|
154
|
+
problems.push(`siteIdExisting count: expected ${CANON_EXPECTED.siteExisting}, found ${counts.siteExisting}`);
|
|
155
|
+
}
|
|
156
|
+
const vocab = new Set(CANON_TYPES);
|
|
157
|
+
for (const t of tables) {
|
|
158
|
+
const hasPk = Object.values(t.columns).some((c) => c.pk === true);
|
|
159
|
+
if (!hasPk)
|
|
160
|
+
problems.push(`primary key missing: ${t.name}`);
|
|
161
|
+
for (const [column, spec] of Object.entries(t.columns)) {
|
|
162
|
+
if (!vocab.has(spec.type))
|
|
163
|
+
problems.push(`column vocabulary ${t.name}.${column}: ${spec.type}`);
|
|
164
|
+
}
|
|
165
|
+
if (!isSiteScoped(t))
|
|
166
|
+
continue;
|
|
167
|
+
const col = t.columns.SiteId;
|
|
168
|
+
if (!col) {
|
|
169
|
+
problems.push(`SiteId missing: ${t.name}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (col.nullable === true)
|
|
173
|
+
problems.push(`SiteId nullable: ${t.name}`);
|
|
174
|
+
if (col.type !== 'uuid')
|
|
175
|
+
problems.push(`SiteId type is ${col.type}, expected uuid: ${t.name}`);
|
|
176
|
+
const indexed = (t.indexes ?? []).some((ix) => ix.columns?.[0] === 'SiteId');
|
|
177
|
+
if (!indexed)
|
|
178
|
+
problems.push(`SiteId index missing: ${t.name}`);
|
|
179
|
+
}
|
|
180
|
+
return { ok: problems.length === 0, problems, counts, legacy: { ...LEGACY_SLICE, removedTables: LEGACY_REMOVED_TABLES } };
|
|
181
|
+
};
|
|
182
|
+
// ------------------------------------------------------------------ CLI ---
|
|
183
|
+
const report = () => {
|
|
184
|
+
const result = auditCanon();
|
|
185
|
+
const { counts } = result;
|
|
186
|
+
console.log(`[canon] tables=${counts.total} site=${counts.site} non-site=${counts.nonSite}`);
|
|
187
|
+
console.log(`[canon] control=${counts.control} registry=${counts.registry} dictionary=${counts.dictionary}`);
|
|
188
|
+
console.log(`[canon] siteIdAdded=${counts.siteIdAdded} siteIdExisting=${counts.siteExisting}`);
|
|
189
|
+
console.log(`[reconcile] legacy ${LEGACY_SLICE.total}/${LEGACY_SLICE.site} - [${LEGACY_REMOVED_TABLES.join(',')}] = ${counts.total}/${counts.site}`);
|
|
190
|
+
if (result.ok) {
|
|
191
|
+
console.log('PASS canon audit: numbers match the annotated decisions');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
console.error(`FAIL canon audit: ${result.problems.length} discrepancy(ies)`);
|
|
195
|
+
for (const p of result.problems)
|
|
196
|
+
console.error(` - ${p}`);
|
|
197
|
+
}
|
|
198
|
+
return result.ok;
|
|
199
|
+
};
|
|
200
|
+
if (process.argv[1]) {
|
|
201
|
+
const self = fileURLToPath(import.meta.url);
|
|
202
|
+
let direct = false;
|
|
203
|
+
try {
|
|
204
|
+
// resolve symlinks when the entry path exists; fall back to a plain
|
|
205
|
+
// resolve when it does not, so a missing path never crashes the oracle.
|
|
206
|
+
direct = self === fs.realpathSync(process.argv[1]);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
direct = self === path.resolve(process.argv[1]);
|
|
210
|
+
}
|
|
211
|
+
if (direct)
|
|
212
|
+
process.exit(report() ? 0 : 1);
|
|
213
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Smoke check for the canon generators (task:3820): materialise the generated
|
|
2
|
+
// Postgres DDL in a live database and report the counts. No quarantine row is
|
|
3
|
+
// seeded: the canon carries none (task:4052) and nothing is parked (task:4092).
|
|
4
|
+
//
|
|
5
|
+
// Needs: ADMIN_PG_URL (to create the throwaway database), TEST_PG_URL (host).
|
|
6
|
+
// Run: ADMIN_PG_URL=... TEST_PG_URL=... node packages/data/scripts/canon-smoke.mjs
|
|
7
|
+
// Smoke check: canon -> generated DDL -> live Postgres.
|
|
8
|
+
import { Client } from 'pg';
|
|
9
|
+
import { renderDdlSql } from './schema/generate.mjs';
|
|
10
|
+
import { canonicalSchema } from "./schema/index.js";
|
|
11
|
+
const admin = new Client({ connectionString: process.env.ADMIN_PG_URL });
|
|
12
|
+
await admin.connect();
|
|
13
|
+
await admin.query('drop database if exists canon');
|
|
14
|
+
await admin.query('create database canon');
|
|
15
|
+
await admin.end();
|
|
16
|
+
const db = new Client({ connectionString: process.env.TEST_PG_URL.replace(/\/spike$/, '/canon') });
|
|
17
|
+
await db.connect();
|
|
18
|
+
await db.query(renderDdlSql('postgres'));
|
|
19
|
+
const quote = (s) => `"${s}"`;
|
|
20
|
+
// fill NOT NULL columns that carry no default with a placeholder of the right type
|
|
21
|
+
const fill = (table, values) => {
|
|
22
|
+
const spec = canonicalSchema.tables.find((t) => t.name === table);
|
|
23
|
+
const cols = { ...values };
|
|
24
|
+
for (const [name, c] of Object.entries(spec.columns)) {
|
|
25
|
+
if (c.nullable === true || c.default !== undefined || name in cols)
|
|
26
|
+
continue;
|
|
27
|
+
cols[name] = c.type === 'bool' ? false
|
|
28
|
+
: ['int', 'bigint', 'decimal'].includes(c.type) ? 0
|
|
29
|
+
: c.type === 'uuid' ? '33333333-3333-3333-3333-333333333333'
|
|
30
|
+
: c.type === 'datetime' ? new Date().toISOString()
|
|
31
|
+
: 'x';
|
|
32
|
+
}
|
|
33
|
+
const names = Object.keys(cols);
|
|
34
|
+
return { text: `insert into ${quote(table)} (${names.map(quote).join(', ')}) values (${names.map((_, i) => `$${i + 1}`).join(', ')})`, values: names.map((n) => cols[n]) };
|
|
35
|
+
};
|
|
36
|
+
const account = fill('Accounts', { AccountId: '11111111-1111-1111-1111-111111111111' });
|
|
37
|
+
const administrator = fill('Administrators', { AdministratorId: '22222222-2222-2222-2222-222222222222' });
|
|
38
|
+
await db.query(account.text, account.values);
|
|
39
|
+
await db.query(administrator.text, administrator.values).catch((e) => console.log('administrators skipped:', e.message));
|
|
40
|
+
const one = async (sql) => (await db.query(sql)).rows[0].c;
|
|
41
|
+
console.log('tables:', await one(`select count(*)::int as c from information_schema.tables where table_schema='public' and table_type='BASE TABLE'`));
|
|
42
|
+
console.log('SiteId indexes:', await one(`select count(*)::int as c from pg_indexes where schemaname='public' and indexname like '%SiteId_idx'`));
|
|
43
|
+
console.log('FK constraints:', await one(`select count(*)::int as c from information_schema.table_constraints where constraint_type='FOREIGN KEY'`));
|
|
44
|
+
await db.end();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Full-canon DDL acceptance on the stand (task:3822, plan:49 Q6).
|
|
2
|
+
//
|
|
3
|
+
// Executes the *whole* generated DDL (122 tables) on every dialect it can
|
|
4
|
+
// reach: SQLite in memory always; Postgres by TEST_PG_URL; MariaDB by
|
|
5
|
+
// TEST_MARIA_URL. It is deliberately not a test file — on MariaDB the ~390
|
|
6
|
+
// statements take minutes on a Windows Docker volume, which is a stand-check
|
|
7
|
+
// cost, not a unit-test one. The fast scenario smoke (same dialects, two tables)
|
|
8
|
+
// is packages/data/test/dialect-smoke.test.js.
|
|
9
|
+
//
|
|
10
|
+
// TEST_PG_URL=... TEST_MARIA_URL=... TEST_MSSQL_URL=... node packages/data/scripts/dialect-ddl-stand.mjs
|
|
11
|
+
//
|
|
12
|
+
// SQL Server is a TARGET since task:3994, so it executes the same canon DDL — the ADO form
|
|
13
|
+
// of the DSN (`Server=…;Database=…;User Id=…;Password=…`) is what `openDb` expects.
|
|
14
|
+
//
|
|
15
|
+
// One line per dialect with the table count and elapsed ms; exits 1 on the
|
|
16
|
+
// first dialect that cannot take the canon DDL — that failure *is* the finding.
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
18
|
+
import { renderDdlSql } from '../schema/generate.mjs';
|
|
19
|
+
const PG_SCHEMA = 'canon_ddl_stand';
|
|
20
|
+
const MARIA_DATABASE = 'canon_ddl_stand';
|
|
21
|
+
const postgres = async () => {
|
|
22
|
+
const url = process.env.TEST_PG_URL;
|
|
23
|
+
if (!url)
|
|
24
|
+
return null;
|
|
25
|
+
const { Client } = await import('pg');
|
|
26
|
+
const client = new Client({ connectionString: url });
|
|
27
|
+
await client.connect();
|
|
28
|
+
try {
|
|
29
|
+
await client.query(`drop schema if exists ${PG_SCHEMA} cascade`);
|
|
30
|
+
await client.query(`create schema ${PG_SCHEMA}`);
|
|
31
|
+
await client.query(`set search_path to ${PG_SCHEMA}`);
|
|
32
|
+
await client.query(renderDdlSql('postgres'));
|
|
33
|
+
const { rows } = await client.query(`select count(*)::int as c from information_schema.tables where table_schema = $1`, [PG_SCHEMA]);
|
|
34
|
+
return rows[0].c;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await client.end();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const mariadb = async () => {
|
|
41
|
+
const url = process.env.TEST_MARIA_URL;
|
|
42
|
+
if (!url)
|
|
43
|
+
return null;
|
|
44
|
+
const parsed = new URL(url);
|
|
45
|
+
const promise = (await import('mysql2/promise')).default;
|
|
46
|
+
const connection = await promise.createConnection({
|
|
47
|
+
host: parsed.hostname,
|
|
48
|
+
port: Number(parsed.port || 3306),
|
|
49
|
+
user: decodeURIComponent(parsed.username),
|
|
50
|
+
password: decodeURIComponent(parsed.password),
|
|
51
|
+
multipleStatements: true,
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await connection.query(`drop database if exists ${MARIA_DATABASE}`);
|
|
55
|
+
await connection.query(`create database ${MARIA_DATABASE} character set utf8mb4 collate utf8mb4_unicode_ci`);
|
|
56
|
+
await connection.query(`use ${MARIA_DATABASE}`);
|
|
57
|
+
await connection.query(renderDdlSql('mariadb'));
|
|
58
|
+
const [rows] = await connection.query(`select count(*) as c from information_schema.tables where table_schema = ?`, [MARIA_DATABASE]);
|
|
59
|
+
return Number(rows[0].c);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await connection.end();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const sqlite = async () => {
|
|
66
|
+
const database = new DatabaseSync(':memory:');
|
|
67
|
+
try {
|
|
68
|
+
database.exec(renderDdlSql('sqlite'));
|
|
69
|
+
return database.prepare(`select count(*) as c from sqlite_master where type = 'table'`).get().c;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
database.close();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* SQL Server as a *target* (task:3994 — the operator made it one). It joins the stand
|
|
77
|
+
* through the same `openDb`, so the DDL is executed by the same dialect the product
|
|
78
|
+
* serves: the ADO DSN splits on `;`, hence the manual field read.
|
|
79
|
+
*/
|
|
80
|
+
const mssql = async () => {
|
|
81
|
+
const dsn = process.env.TEST_MSSQL_URL;
|
|
82
|
+
if (!dsn)
|
|
83
|
+
return null;
|
|
84
|
+
const { openDb } = await import('../dbProvider.js');
|
|
85
|
+
const { sql } = await import('kysely');
|
|
86
|
+
const database = process.env.TEST_MSSQL_DATABASE ?? 'canon_ddl_stand';
|
|
87
|
+
const onDatabase = (name) => dsn.replace(/(database|initial catalog)=[^;]*/i, `Database=${name}`);
|
|
88
|
+
const master = await openDb({ dialect: 'mssql', dsn: onDatabase('master') });
|
|
89
|
+
try {
|
|
90
|
+
await sql.raw(`if db_id('${database}') is not null begin alter database [${database}] set single_user with rollback immediate; drop database [${database}]; end`).execute(master);
|
|
91
|
+
await sql.raw(`create database [${database}]`).execute(master);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await master.destroy();
|
|
95
|
+
}
|
|
96
|
+
const db = await openDb({ dialect: 'mssql', dsn: onDatabase(database) });
|
|
97
|
+
try {
|
|
98
|
+
// the DDL header comments are not statements; the driver takes one at a time
|
|
99
|
+
const sqlText = renderDdlSql('mssql')
|
|
100
|
+
.split('\n')
|
|
101
|
+
.filter((line) => !line.trim().startsWith('--'))
|
|
102
|
+
.join('\n');
|
|
103
|
+
for (const statement of sqlText.split(/;\s*\n/).map((part) => part.trim()).filter(Boolean)) {
|
|
104
|
+
await sql.raw(statement).execute(db);
|
|
105
|
+
}
|
|
106
|
+
const count = await sql `select count(*) as c from sys.tables`.execute(db);
|
|
107
|
+
return Number(count.rows[0].c);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await db.destroy();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const dialects = [
|
|
114
|
+
['sqlite', sqlite],
|
|
115
|
+
['postgres', postgres],
|
|
116
|
+
['mariadb', mariadb],
|
|
117
|
+
['mssql', mssql],
|
|
118
|
+
];
|
|
119
|
+
let failed = false;
|
|
120
|
+
for (const [name, run] of dialects) {
|
|
121
|
+
const started = Date.now();
|
|
122
|
+
try {
|
|
123
|
+
const tables = await run();
|
|
124
|
+
if (tables === null) {
|
|
125
|
+
console.log(`${name.padEnd(9)} SKIP (no URL in the environment)`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.log(`${name.padEnd(9)} ok — ${tables} tables in ${Date.now() - started} ms`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
failed = true;
|
|
133
|
+
console.log(`${name.padEnd(9)} FAIL — ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Verifier for task:3818 — the owner-name resolution spec, moved into the
|
|
2
|
+
// repository by task:3863 (it used to live in a scratch folder outside version
|
|
3
|
+
// control, next to a hardcoded absolute path).
|
|
4
|
+
//
|
|
5
|
+
// Checks the contract `OwnerName -> (table, pk)` against two independent facts:
|
|
6
|
+
// (1) the frozen live snapshot `owners/owners-observed.json` (a read-only probe
|
|
7
|
+
// of production, 2026-10-13) and (2) the canonical schema in this repository, so
|
|
8
|
+
// a spec entry pointing at a dropped / misspelled table cannot pass.
|
|
9
|
+
//
|
|
10
|
+
// Deterministic: no DB access, no network, no absolute paths.
|
|
11
|
+
// node packages/data/scripts/owner-spec-check.mjs
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { canonicalSchema } from "../schema/index.js";
|
|
15
|
+
import { loadContract } from './owners-fixture.mjs';
|
|
16
|
+
const { spec, observed } = loadContract();
|
|
17
|
+
const canonTables = new Map(canonicalSchema.tables.map((t) => [t.name, new Set(Object.keys(t.columns))]));
|
|
18
|
+
const hasColumn = (table, column) => canonTables.get(table)?.has(column) === true;
|
|
19
|
+
/** The declared primary key of a canon table — the column a resolver must join on. */
|
|
20
|
+
const pkOf = (table) => {
|
|
21
|
+
const spec = canonicalSchema.tables.find((t) => t.name === table);
|
|
22
|
+
return spec && Object.keys(spec.columns).find((c) => spec.columns[c].pk);
|
|
23
|
+
};
|
|
24
|
+
const failures = [];
|
|
25
|
+
const check = (fn, label) => { try {
|
|
26
|
+
fn();
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
failures.push(`${label}: ${e.message}`);
|
|
30
|
+
} };
|
|
31
|
+
// --- 1. observed set is covered 100% and exactly (no invented, no missing) --
|
|
32
|
+
const observedNames = Object.keys(observed.gallery.byOwnerName).sort();
|
|
33
|
+
const specNames = Object.keys(spec.observedOwners).sort();
|
|
34
|
+
check(() => {
|
|
35
|
+
const missing = observedNames.filter((n) => !specNames.includes(n));
|
|
36
|
+
assert.equal(missing.length, 0, `not covered by the spec: ${missing.join(', ')}`);
|
|
37
|
+
}, 'coverage');
|
|
38
|
+
check(() => {
|
|
39
|
+
const extra = specNames.filter((n) => !observedNames.includes(n));
|
|
40
|
+
assert.equal(extra.length, 0, `spec has values that never occur in production: ${extra.join(', ')}`);
|
|
41
|
+
}, 'exactness');
|
|
42
|
+
check(() => {
|
|
43
|
+
assert.equal(specNames.length, 12, `expected 12 observed OwnerName values, spec has ${specNames.length}`);
|
|
44
|
+
}, 'observed count');
|
|
45
|
+
// --- 2. per-value target exists in the canon + row/orphan numbers match ----
|
|
46
|
+
for (const name of specNames.filter((n) => observedNames.includes(n))) {
|
|
47
|
+
const entry = spec.observedOwners[name];
|
|
48
|
+
const o = observed.gallery.byOwnerName[name];
|
|
49
|
+
check(() => {
|
|
50
|
+
assert.equal(entry.rows, o, `${name}: spec rows=${entry.rows} != snapshot ${o}`);
|
|
51
|
+
}, `rows:${name}`);
|
|
52
|
+
check(() => {
|
|
53
|
+
const orph = observed.gallery.orphansByOwnerName[name] ?? 0;
|
|
54
|
+
assert.equal(entry.orphans, orph, `${name}: spec orphans=${entry.orphans} != snapshot ${orph}`);
|
|
55
|
+
}, `orphans:${name}`);
|
|
56
|
+
check(() => {
|
|
57
|
+
if (entry.status === 'resolvable') {
|
|
58
|
+
assert.ok(entry.table && entry.pk, `${name}: resolvable entry needs table+pk`);
|
|
59
|
+
assert.ok(hasColumn(entry.table, entry.pk), `${name}: canon has no ${entry.table}.${entry.pk}`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
assert.equal(entry.status, 'quarantine-only', `${name}: unknown status ${entry.status}`);
|
|
63
|
+
assert.ok(entry.reason, `${name}: quarantine-only needs a reason`);
|
|
64
|
+
// a quarantined value either has no target at all or has one that is
|
|
65
|
+
// already dropped from the canon — say which, explicitly
|
|
66
|
+
if (entry.table) {
|
|
67
|
+
assert.equal(canonTables.has(entry.table), false, `${name}: quarantined but ${entry.table} still lives in the canon`);
|
|
68
|
+
}
|
|
69
|
+
assert.equal(entry.orphans, entry.rows, `${name}: quarantine-only must be orphan in full`);
|
|
70
|
+
}
|
|
71
|
+
}, `target:${name}`);
|
|
72
|
+
}
|
|
73
|
+
// --- 3. totals: the distribution adds up to the live total ------------------
|
|
74
|
+
check(() => {
|
|
75
|
+
const sum = Object.values(observed.gallery.byOwnerName).reduce((a, b) => a + b, 0);
|
|
76
|
+
assert.equal(sum, observed.gallery.total, `distribution ${sum} != gallery total ${observed.gallery.total}`);
|
|
77
|
+
assert.equal(spec.observedTotals.Gallery, observed.gallery.total, 'spec total != snapshot total');
|
|
78
|
+
assert.equal(spec.observedTotals.orphans, observed.gallery.orphansTotal, 'spec orphans != snapshot orphans');
|
|
79
|
+
assert.equal(spec.observedTotals.idOwnerNull, observed.gallery.idOwnerNull, 'spec idOwnerNull != snapshot');
|
|
80
|
+
}, 'totals');
|
|
81
|
+
// --- 4. poly-roots have explicit, complete resolution rules -----------------
|
|
82
|
+
// Three declared forms (task:3895): an owner column, host columns, or an explicit `resolvers`
|
|
83
|
+
// list. A root declared `siteFrom: 'none'` carries no site of its own and declares none of them.
|
|
84
|
+
for (const [root, rule] of Object.entries(spec.polyRoots)) {
|
|
85
|
+
check(() => {
|
|
86
|
+
assert.ok(canonTables.has(root), `${root}: not a canon table`);
|
|
87
|
+
if (rule.siteFrom === 'none') {
|
|
88
|
+
assert.ok(!rule.resolvers && !rule.hostColumns && !rule.ownerColumn, `${root}: declares no site, so it resolves through nothing`);
|
|
89
|
+
}
|
|
90
|
+
else if (rule.resolvers) {
|
|
91
|
+
assert.ok(Array.isArray(rule.resolvers) && rule.resolvers.length, `${root}: resolvers must be a non-empty list`);
|
|
92
|
+
for (const link of rule.resolvers) {
|
|
93
|
+
assert.ok(hasColumn(root, link.column), `${root}: canon has no ${link.column}`);
|
|
94
|
+
assert.ok(canonTables.has(link.table), `${root}: resolver table ${link.table} is not in the canon`);
|
|
95
|
+
assert.equal(pkOf(link.table), link.pk, `${root}: ${link.table} has no primary key ${link.pk}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (rule.hostColumns) {
|
|
99
|
+
for (const c of rule.hostColumns)
|
|
100
|
+
assert.ok(hasColumn(root, c), `${root}: canon has no ${c}`);
|
|
101
|
+
for (const [col, table] of Object.entries(rule.hostTables)) {
|
|
102
|
+
assert.ok(rule.hostColumns.includes(col), `${root}: hostTables.${col} not in hostColumns`);
|
|
103
|
+
assert.ok(canonTables.has(table), `${root}: host table ${table} is not in the canon`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
assert.ok(rule.ownerColumn, `${root}: needs ownerColumn, hostColumns or resolvers`);
|
|
108
|
+
assert.ok(hasColumn(root, rule.ownerColumn), `${root}: canon has no ${rule.ownerColumn}`);
|
|
109
|
+
}
|
|
110
|
+
if (rule.siteFrom === 'none') {
|
|
111
|
+
assert.equal(rule.unresolved, undefined, `${root}: nothing resolves, so nothing is quarantined by it`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// the site comes from the resolved parent row — a polymorphic owner (`owner-row`) or a
|
|
115
|
+
// declared parent chain (`parent-chain`); the global policy answers for `unresolved`
|
|
116
|
+
assert.ok(['owner-row', 'parent-chain'].includes(rule.siteFrom), `${root}: site must come from the resolved owner/host row (got ${JSON.stringify(rule.siteFrom)})`);
|
|
117
|
+
assert.equal(rule.unresolved ?? spec.unresolved?.policy, 'refuse', `${root}: unresolved rows must refuse the run, not be parked`);
|
|
118
|
+
}, `polyRoot:${root}`);
|
|
119
|
+
}
|
|
120
|
+
check(() => {
|
|
121
|
+
for (const needed of ['Gallery', 'Modules', '_Modules'])
|
|
122
|
+
assert.ok(spec.polyRoots[needed], `poly-roots must cover ${needed}`);
|
|
123
|
+
}, 'polyRoots present');
|
|
124
|
+
// --- 5. orphans policy: refuse, never park (task:4092) ---------------------
|
|
125
|
+
// A row that ends the run without a site is a missing edge, so the contract cannot offer a place
|
|
126
|
+
// to put it: the cutover refuses by name and `--purge` (with a manifest) is the only exit.
|
|
127
|
+
check(() => {
|
|
128
|
+
assert.equal(spec.unresolved.policy, 'refuse', 'there is no parking policy: an unplaced row must stop the run');
|
|
129
|
+
assert.ok(String(spec.unresolved.exit).includes('--purge'), 'the only exit is an explicit, auditable delete');
|
|
130
|
+
assert.equal(spec.unresolved.neverDelete, undefined, 'the never-delete verdict is gone with the sentinel');
|
|
131
|
+
assert.equal(spec.unresolved.sentinelSiteId, undefined, 'no sentinel site: the migration invents no tenant');
|
|
132
|
+
assert.equal(spec.unresolved.sentinelMarker, undefined, 'and marks no row');
|
|
133
|
+
}, 'unresolved policy');
|
|
134
|
+
// --- 6. numbering rules exist and match the live seed ----------------------
|
|
135
|
+
check(() => {
|
|
136
|
+
assert.equal(spec.numbering.tasks.seed, observed.numbering.tasks, 'numbering task seed != live 4 defaults');
|
|
137
|
+
assert.equal(spec.numbering.counters.rows, observed.numbering.counters, 'numbering counters != live 86');
|
|
138
|
+
assert.equal(spec.numbering.counters.distinctOwners, observed.numbering.counterOwners, 'distinct numbering owners != live 85');
|
|
139
|
+
assert.ok(spec.numbering.counters.siteFrom, 'numbering counters need a site rule');
|
|
140
|
+
}, 'numbering');
|
|
141
|
+
// --- 7. aliases never dangle ------------------------------------------------
|
|
142
|
+
check(() => {
|
|
143
|
+
const targets = new Set([...specNames, ...Object.keys(spec.aliases)]);
|
|
144
|
+
for (const [alias, rule] of Object.entries(spec.aliases)) {
|
|
145
|
+
if (rule.aliasOf)
|
|
146
|
+
assert.ok(targets.has(rule.aliasOf), `alias ${alias} -> unknown ${rule.aliasOf}`);
|
|
147
|
+
else {
|
|
148
|
+
assert.ok(rule.table && rule.pk, `alias ${alias}: needs table+pk or aliasOf`);
|
|
149
|
+
assert.ok(hasColumn(rule.table, rule.pk), `alias ${alias}: canon has no ${rule.table}.${rule.pk}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
assert.ok(Object.keys(spec.aliases).length > 0, 'aliases must cover the legacy write-path variants');
|
|
153
|
+
}, 'aliases');
|
|
154
|
+
// --- verdict ---------------------------------------------------------------
|
|
155
|
+
const covered = specNames.length;
|
|
156
|
+
const resolvable = specNames.filter((n) => spec.observedOwners[n].status === 'resolvable').length;
|
|
157
|
+
if (failures.length) {
|
|
158
|
+
console.error(`FAIL owner-resolution spec: ${failures.length} problem(s)`);
|
|
159
|
+
for (const f of failures)
|
|
160
|
+
console.error(' - ' + f);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
console.log('PASS owner-resolution spec');
|
|
164
|
+
console.log(` observed OwnerName values: ${covered}/${observedNames.length} covered (${resolvable} resolvable, ${covered - resolvable} quarantine-only)`);
|
|
165
|
+
console.log(` Gallery rows: ${observed.gallery.total}, orphan: ${observed.gallery.orphansTotal}, IdOwner NULL: ${observed.gallery.idOwnerNull}`);
|
|
166
|
+
console.log(` poly-roots: ${Object.keys(spec.polyRoots).join(', ')} | aliases: ${Object.keys(spec.aliases).length} | unresolved: ${spec.unresolved.policy}`);
|