@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,262 @@
1
+ // The render-parity harness (task:3824, plan:50 Q7, requirements:66 п.7): the
2
+ // plumbing that decides whether the same engine renders the same bytes from a
3
+ // different DSN.
4
+ //
5
+ // Not a test file and not a runner: capture / normalize / diff / report are pure
6
+ // enough to be unit-tested, and the same functions are what the cutover runbook
7
+ // (task:3828) will wrap in a CLI. Nothing here boots an engine or opens a
8
+ // database — an engine reaches the harness as an origin (a base URL), which is
9
+ // what keeps "run it against any target DSN" true.
10
+ //
11
+ // const pages = await loadPageSet();
12
+ // const from = await captureSite({ origin: baseline, pages });
13
+ // const to = await captureSite({ origin: target, pages });
14
+ // const diffs = diffSnapshots(from, to);
15
+ // if (diffs.length) throw new Error(formatReport({ baseline, target, diffs }));
16
+ import { readFile } from 'node:fs/promises';
17
+ /** The fixed capture kinds (plan:50 Q7.1); a page set must match this exactly. */
18
+ export const PAGE_KINDS = [
19
+ 'home',
20
+ 'catalog',
21
+ 'catalog-pagination',
22
+ 'product',
23
+ 'post',
24
+ 'page',
25
+ 'sitemap',
26
+ 'robots',
27
+ 'redirect',
28
+ 'redirect',
29
+ ];
30
+ const DEFAULT_PAGE_SET = new URL('../fixtures/render-parity/pages.json', import.meta.url);
31
+ /**
32
+ * Loads a page set — the shipped fixture by default, or an explicit file (a site
33
+ * passes its own real URLs, e.g. «Стайер»'s catalog paths, via TEST_RENDER_PAGES).
34
+ * The set is a fixed denominator: it must match PAGE_KINDS exactly and may not
35
+ * repeat a name or a path, so a silently shrinking set can never silently weaken
36
+ * the check.
37
+ */
38
+ export const loadPageSet = async (source = undefined) => {
39
+ const raw = source === undefined
40
+ ? JSON.parse(await readFile(DEFAULT_PAGE_SET, 'utf8'))
41
+ : typeof source === 'string' || source instanceof URL
42
+ ? JSON.parse(await readFile(source, 'utf8'))
43
+ : source;
44
+ const pages = Array.isArray(raw) ? raw : raw.pages;
45
+ if (!Array.isArray(pages) || !pages.length)
46
+ throw new Error('page set: no pages');
47
+ for (const page of pages) {
48
+ if (!page || typeof page.name !== 'string' || typeof page.path !== 'string' || typeof page.kind !== 'string') {
49
+ throw new Error('page set: every page needs { name, kind, path }');
50
+ }
51
+ if (!PAGE_KINDS.includes(page.kind))
52
+ throw new Error(`page set: unknown kind "${page.kind}"`);
53
+ if (!page.path.startsWith('/') || page.path.startsWith('//') || page.path.includes('..')) {
54
+ throw new Error(`page set: "${page.name}" path must be an origin-relative path`);
55
+ }
56
+ }
57
+ if (new Set(pages.map((page) => page.name)).size !== pages.length)
58
+ throw new Error('page set: duplicated name');
59
+ if (new Set(pages.map((page) => page.path)).size !== pages.length)
60
+ throw new Error('page set: duplicated path');
61
+ if (pages.length !== PAGE_KINDS.length || pages.filter((page) => page.kind === 'redirect').length !== 2) {
62
+ throw new Error(`page set: kinds must be exactly the ${PAGE_KINDS.length} Q7 captures (redirect ×2)`);
63
+ }
64
+ for (const kind of PAGE_KINDS) {
65
+ if (!pages.some((page) => page.kind === kind))
66
+ throw new Error(`page set: kind "${kind}" is not captured`);
67
+ }
68
+ return pages;
69
+ };
70
+ // Cache-busting suffixes the two engines add differently (`?v=`, `?ver=`, `?cb=`,
71
+ // `?t=`, `?_=`) — noise for parity, never content. Matched only as whole query
72
+ // parameter names so `?width=` or `?video=` survive.
73
+ const CACHE_BUST = /([?&])(?:v|ver|cb|t|_)=[^"'&<>#\s]*/g;
74
+ /** Query-string leftovers once the cache-busting parameters are gone (`/a.css?` → `/a.css`). */
75
+ const tidyQuery = (text) => text
76
+ .replace(/\?&/g, '?')
77
+ .replace(/&&+/g, '&')
78
+ .replace(/[?&](?=["'<>\s]|#|$)/g, '');
79
+ /** Drops cache-busting parameters from one URL. */
80
+ export const stripCacheBust = (url) => tidyQuery(url.replace(CACHE_BUST, '$1'));
81
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ /**
83
+ * The served origin is noise; a foreign one is content. Boundary-aware, so an
84
+ * origin that merely prefixes another (`:3000` vs `:30001`) is left alone. An
85
+ * origin embedded inside a *foreign* URL's query is still relativized — accepted:
86
+ * it costs a diff excerpt, not a silent pass (the pair of engines is the same
87
+ * codebase, so a foreign-URL-embedding query is not where parity breaks).
88
+ */
89
+ const relativizeOrigin = (text, origin) => text.replace(new RegExp(`${escapeRegExp(origin)}(?=[/?#"'<>\s]|$)`, 'g'), '');
90
+ /** HTML tags whose attributes are order-noise; comments, doctype and closing tags are left alone. */
91
+ const TAG = /<(\/?)([a-zA-Z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
92
+ const ATTRIBUTE = /([^\s=/>]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
93
+ /** Codepoint order, not `localeCompare`: attribute order must not depend on the machine's locale. */
94
+ const byCodepoint = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
95
+ const orderAttributes = (body) => body.replace(TAG, (match, close, tag, attrs) => {
96
+ if (!attrs.trim())
97
+ return `<${close}${tag}>`;
98
+ const selfClosing = /\/\s*$/.test(attrs);
99
+ const pairs = [];
100
+ for (const found of attrs.matchAll(ATTRIBUTE))
101
+ pairs.push(found[2] ? `${found[1]}=${found[2]}` : found[1]);
102
+ pairs.sort(byCodepoint);
103
+ return `<${close}${tag} ${pairs.join(' ')}${selfClosing ? '/' : ''}>`;
104
+ });
105
+ const collapseWhitespace = (text) => text
106
+ .split('\n')
107
+ .map((line) => line.replace(/[^\S\n]+/g, ' ').trim())
108
+ .filter((line) => line.length > 0)
109
+ .join('\n');
110
+ /** Markup segment: URL noise and cache-busting go, whitespace collapses, attributes order (HTML only). */
111
+ const normalizeSegment = (text, { origin, order }) => {
112
+ let out = origin ? relativizeOrigin(text, origin) : text;
113
+ out = tidyQuery(out.replace(CACHE_BUST, '$1'));
114
+ out = collapseWhitespace(out);
115
+ return order ? orderAttributes(out) : out;
116
+ };
117
+ // Raw-text elements: attribute reordering inside a script body would corrupt code,
118
+ // and whitespace inside pre/textarea is content. Only script/style bodies get the
119
+ // whitespace collapse (the two engines render the same templates, so a difference
120
+ // in inline JS string whitespace is not what parity is about).
121
+ const RAW_ELEMENT = /<(script|style|pre|textarea)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
122
+ const VERBATIM = new Set(['pre', 'textarea']);
123
+ const normalizeRawElement = (match, tag, { origin, order }) => match.replace(new RegExp(`(<${tag}\\b[^>]*>)([\\s\\S]*)(</${tag}\\s*>)`, 'i'), (whole, open, body, close) => `${normalizeSegment(open, { origin, order })}\n${collapseWhitespace(body)}\n${close}`);
124
+ /**
125
+ * Normalizes one rendered body for the diff (plan:50 Q7.2): absolute URLs of the
126
+ * served origin become relative, cache-busting suffixes go, attributes are
127
+ * ordered, whitespace is collapsed line-wise with blank lines dropped.
128
+ *
129
+ * Only markup (HTML/XML) is URL-normalized: robots.txt is content, and a
130
+ * `Disallow: /a?t=1` line must survive verbatim. XML keeps attribute order —
131
+ * `xmlns` is not noise.
132
+ */
133
+ export const normalizeText = (text, { origin, contentType = '' } = {}) => {
134
+ const base = origin ? origin.replace(/\/+$/, '') : '';
135
+ const type = String(contentType);
136
+ const markup = type === '' || /(html|xml)/i.test(type);
137
+ const order = /html/i.test(type);
138
+ let out = String(text).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
139
+ if (!markup)
140
+ return collapseWhitespace(out);
141
+ const pieces = [];
142
+ let cursor = 0;
143
+ out.replace(RAW_ELEMENT, (match, tag, offset) => {
144
+ pieces.push(normalizeSegment(out.slice(cursor, offset), { origin: base, order }));
145
+ pieces.push(VERBATIM.has(tag.toLowerCase())
146
+ ? match
147
+ : normalizeRawElement(match, tag, { origin: base, order }));
148
+ cursor = offset + match.length;
149
+ return match;
150
+ });
151
+ pieces.push(normalizeSegment(out.slice(cursor), { origin: base, order }));
152
+ return pieces.filter((piece) => piece !== '').join('\n');
153
+ };
154
+ /** `normalizeText` for markup — the common case. */
155
+ export const normalizeHtml = (html, { origin } = {}) => normalizeText(html, { origin, contentType: 'text/html' });
156
+ /** A redirect target is content, but its origin is not: same-origin → relative. */
157
+ const normalizeLocation = (location, origin) => {
158
+ if (!location)
159
+ return null;
160
+ const base = origin.replace(/\/+$/, '');
161
+ return location.startsWith(base) ? location.slice(base.length) || '/' : location;
162
+ };
163
+ /**
164
+ * Snapshots a fixed page set from a running engine. Redirects are recorded, not
165
+ * followed (`redirect: 'manual'`): a 301 whose target drifts is exactly the kind
166
+ * of regression this harness exists to see. A page that does not answer at all is
167
+ * recorded with `status: null` and its error — a failed capture must reach the
168
+ * report, never abort the run without a page name.
169
+ */
170
+ export const captureSite = async ({ origin, pages = undefined, fetchImpl = fetch, timeoutMs = 30000 }) => {
171
+ const base = origin.replace(/\/+$/, '');
172
+ const set = pages ?? (await loadPageSet());
173
+ const captured = [];
174
+ for (const page of set) {
175
+ try {
176
+ const response = await fetchImpl(`${base}${page.path}`, {
177
+ redirect: 'manual',
178
+ signal: AbortSignal.timeout(timeoutMs),
179
+ });
180
+ const contentType = response.headers.get('content-type') ?? '';
181
+ const location = response.headers.get('location');
182
+ captured.push({
183
+ name: page.name,
184
+ kind: page.kind,
185
+ path: page.path,
186
+ status: response.status,
187
+ location: normalizeLocation(location, base),
188
+ error: null,
189
+ body: normalizeText(await response.text(), { origin: base, contentType }),
190
+ });
191
+ }
192
+ catch (error) {
193
+ captured.push({
194
+ name: page.name,
195
+ kind: page.kind,
196
+ path: page.path,
197
+ status: null,
198
+ location: null,
199
+ error: String(error?.message ?? error),
200
+ body: '',
201
+ });
202
+ }
203
+ }
204
+ return { origin: base, pages: captured };
205
+ };
206
+ const splitLines = (body) => (body === '' ? [] : body.split('\n'));
207
+ /** First differing lines, capped — the evidence a human reads, not a full dump. */
208
+ const bodyDiff = (baselineBody, targetBody, { max = 3 } = {}) => {
209
+ const from = splitLines(baselineBody);
210
+ const to = splitLines(targetBody);
211
+ const lines = [];
212
+ const length = Math.max(from.length, to.length);
213
+ for (let index = 0; index < length && lines.length < max; index++) {
214
+ if (from[index] === to[index])
215
+ continue;
216
+ lines.push(`line ${index + 1}:`, ` - ${from[index] ?? '<missing>'}`, ` + ${to[index] ?? '<missing>'}`);
217
+ }
218
+ return lines;
219
+ };
220
+ /**
221
+ * Compares two captures by page name. Returns mismatches only: `[]` means the
222
+ * render is identical. Kinds are `diff` (status, redirect target, failure or body
223
+ * moved), `missing` (baseline page the target did not serve) or `extra`.
224
+ */
225
+ export const diffSnapshots = (baseline, target) => {
226
+ const byName = (snapshot) => new Map(snapshot.pages.map((page) => [page.name, page]));
227
+ const from = byName(baseline);
228
+ const to = byName(target);
229
+ const diffs = [];
230
+ for (const [name, left] of from) {
231
+ const right = to.get(name);
232
+ if (!right) {
233
+ diffs.push({ name, kind: 'missing', path: left.path, lines: [`${left.path}: not served by the target`] });
234
+ continue;
235
+ }
236
+ const lines = [];
237
+ if (left.status !== right.status)
238
+ lines.push(`status: ${left.status ?? 'failed'} → ${right.status ?? 'failed'}`);
239
+ if ((left.error ?? null) !== (right.error ?? null))
240
+ lines.push(`error: ${left.error ?? '-'} → ${right.error ?? '-'}`);
241
+ if (left.location !== right.location)
242
+ lines.push(`location: ${left.location ?? '-'} → ${right.location ?? '-'}`);
243
+ if (left.body !== right.body)
244
+ lines.push(...bodyDiff(left.body, right.body));
245
+ if (lines.length)
246
+ diffs.push({ name, kind: 'diff', path: left.path, lines });
247
+ }
248
+ for (const [name, right] of to) {
249
+ if (from.has(name))
250
+ continue;
251
+ diffs.push({ name, kind: 'extra', path: right.path, lines: [`${right.path}: served only by the target`] });
252
+ }
253
+ return diffs;
254
+ };
255
+ /** The report a cutover (task:3828) attaches to its window: pass or the evidence. */
256
+ export const formatReport = ({ baseline, target, diffs }) => {
257
+ const head = [`render-parity: ${baseline} → ${target}`];
258
+ if (!diffs.length)
259
+ return [...head, 'IDENTICAL: every captured page matches after normalization.'].join('\n');
260
+ const body = diffs.map((diff) => [`### ${diff.name} (${diff.path}) — ${diff.kind}`, ...diff.lines.map((line) => ` ${line}`)]);
261
+ return [...head, `${diffs.length} page(s) differ from the baseline:`, ...body.flat()].join('\n');
262
+ };
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
- "name": "@snolla/data",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@snolla/data",
3
+ "type": "module",
4
+ "version": "1.0.7",
5
+ "license": "MIT",
6
+ "dependencies": {
7
+ "config": "^3.3.3",
8
+ "kysely": "^0.29"
9
+ },
10
+ "gitHead": "5124dc5293ec47420def6a96f80976e1ed3f998a",
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "prepare": "yarn build",
14
+ "test": "node --test test/*.test.js",
15
+ "owners:audit": "node scripts/owners-audit.mjs",
16
+ "owners:audit:mssql": "node scripts/owners-audit.mjs --source=mssql --compare",
17
+ "owners:spec": "node scripts/owner-spec-check.mjs",
18
+ "canon:audit": "node scripts/canon-audit.mjs"
19
+ },
20
+ "exports": {
21
+ "./audit.ts": "./dist/audit.js",
22
+ "./dataProvider.js": "./dist/dataProvider.js",
23
+ "./dataSource.js": "./dist/dataSource.js",
24
+ "./dbProvider.js": "./dist/dbProvider.js",
25
+ "./freeze.ts": "./dist/freeze.js",
26
+ "./guard.ts": "./dist/guard.js",
27
+ "./insert.ts": "./dist/insert.js",
28
+ "./singleMode.ts": "./dist/singleMode.js",
29
+ "./sqliteDialect.js": "./dist/sqliteDialect.js",
30
+ "./templateDefs.js": "./dist/templateDefs.js",
31
+ "./schema/index.ts": "./dist/schema/index.js",
32
+ "./schema/generate.mjs": "./dist/schema/generate.mjs",
33
+ "./schema/provision.mjs": "./dist/schema/provision.mjs",
34
+ "./schema/generated/kysely-types": "./dist/schema/generated/kysely-types.js",
35
+ "./test/support/dialect-stand.js": "./dist/test/support/dialect-stand.js",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "!dist/**/*.map"
41
+ ],
42
+ "devDependencies": {
43
+ "typescript": "^5.9.3"
44
+ },
45
+ "optionalDependencies": {
46
+ "mysql2": "^3.24.6",
47
+ "pg": "^8.23.1",
48
+ "tarn": "^3.1.2",
49
+ "tedious": "^9.2.1"
50
+ }
51
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.