@prisma/orm-mongo 8.0.0-rc.18-dev.46 → 8.0.0-rc.19
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/{mongo-static-Be-tE2Ra-a5WLdGKy.mjs → mongo-static-DNXbLhsJ-C9Jn5l2_.mjs} +3 -3
- package/dist/mongo-static-DNXbLhsJ-C9Jn5l2_.mjs.map +1 -0
- package/dist/runtime.mjs +1 -1
- package/dist/runtime.mjs.map +1 -1
- package/dist/static.mjs +1 -1
- package/package.json +8 -8
- package/skills/prisma-8/SKILL.md +1 -1
- package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/instructions.md +1826 -0
- package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/client-takes-contract-value/contract-from-json.ts +382 -0
- package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/data-type-values/data-type-values.ts +1117 -0
- package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/default-generators-to-on-create/rewrite-default-generators.mjs +86 -0
- package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/mongo-field-storage-names/mongo-field-storage-names.ts +250 -0
- package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/remove-nocheck/remove-nocheck.ts +813 -0
- package/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/instructions.md +2000 -0
- package/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/client-takes-contract-value/contract-from-json.ts +382 -0
- package/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.18-to-8.0.0-rc.19/scripts/remove-nocheck/remove-nocheck.ts +813 -0
- package/dist/mongo-static-Be-tE2Ra-a5WLdGKy.mjs.map +0 -1
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Rewrites every stored SQLite contract in a project to this release's form: a column or named type
|
|
4
|
+
* of `sqlite/datetime@1` or `sqlite/json@1` names its own data type (`sqlite/datetime`,
|
|
5
|
+
* `sqlite/json`) instead of `sqlite/text`, and a datetime default or enum value is written with
|
|
6
|
+
* exactly three fraction digits (`2024-01-01T00:00:00.000Z`). It then renames the snapshot
|
|
7
|
+
* directories and rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an
|
|
8
|
+
* old storage hash.
|
|
9
|
+
*
|
|
10
|
+
* Usage: node data-type-values.ts [project-root]
|
|
11
|
+
*
|
|
12
|
+
* The project root defaults to the working directory. The script reads and writes files only. It
|
|
13
|
+
* needs no database, network or configured stack, and a project already in the new form is left
|
|
14
|
+
* unchanged. When it stops (a file that must be JSON is not, or a snapshot directory that already
|
|
15
|
+
* exists with different content) it changes no file, prints one line per case and exits 1. When it
|
|
16
|
+
* finishes it prints how many files and snapshot directories it changed and each storage hash it
|
|
17
|
+
* replaced.
|
|
18
|
+
*
|
|
19
|
+
* The storage hash and migration hash rules are copied from `@internal/contract` and
|
|
20
|
+
* `@internal/migration-tools` because a project's strict `node_modules` does not expose them.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import {
|
|
24
|
+
chmodSync,
|
|
25
|
+
type Dirent,
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
readdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
realpathSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
rmSync,
|
|
33
|
+
statSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
37
|
+
|
|
38
|
+
type Json = null | boolean | number | string | Json[] | JsonRecord;
|
|
39
|
+
interface JsonRecord {
|
|
40
|
+
[key: string]: Json;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DATA_TYPES: Readonly<Record<string, string>> = {
|
|
44
|
+
'sqlite/datetime@1': 'sqlite/datetime',
|
|
45
|
+
'sqlite/json@1': 'sqlite/json',
|
|
46
|
+
};
|
|
47
|
+
const DATETIME_CODEC = 'sqlite/datetime@1';
|
|
48
|
+
const PREVIOUS_DATA_TYPE = 'sqlite/text';
|
|
49
|
+
const UTC_INSTANT = /^(.*:\d{2})(?:\.(\d{1,3}))?Z$/;
|
|
50
|
+
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build']);
|
|
51
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
52
|
+
const TEMPORARY_SUFFIX = '.data-type-values-tmp';
|
|
53
|
+
|
|
54
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
55
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
56
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
57
|
+
return prototype === Object.prototype || prototype === null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseJson(text: string): Json | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const value: Json = JSON.parse(text);
|
|
63
|
+
return value;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sha256(text: string): string {
|
|
70
|
+
return createHash('sha256').update(text).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compareCodeUnits(a: string, b: string): number {
|
|
74
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sortKeys(value: Json): Json {
|
|
78
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
79
|
+
if (!isRecord(value)) return value;
|
|
80
|
+
const sorted: JsonRecord = {};
|
|
81
|
+
for (const key of Object.keys(value).sort(compareCodeUnits)) {
|
|
82
|
+
const child = value[key];
|
|
83
|
+
if (child !== undefined) sorted[key] = sortKeys(child);
|
|
84
|
+
}
|
|
85
|
+
return sorted;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function canonicalizeJson(value: Json): string {
|
|
89
|
+
return JSON.stringify(sortKeys(value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type PathSegment = string | readonly string[];
|
|
93
|
+
|
|
94
|
+
function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
|
|
95
|
+
if (path.length !== pattern.length) return false;
|
|
96
|
+
return pattern.every((segment, index) => {
|
|
97
|
+
const value = path[index];
|
|
98
|
+
if (value === undefined) return false;
|
|
99
|
+
if (segment === '*') return true;
|
|
100
|
+
return typeof segment === 'string' ? value === segment : segment.includes(value);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
|
|
105
|
+
['storage', 'namespaces', '*', 'entries', 'table'],
|
|
106
|
+
['storage', 'namespaces', '*', 'entries', 'table', '*'],
|
|
107
|
+
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
|
|
108
|
+
['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
|
|
109
|
+
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
|
|
110
|
+
];
|
|
111
|
+
const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
|
|
112
|
+
'storage',
|
|
113
|
+
'namespaces',
|
|
114
|
+
'*',
|
|
115
|
+
'entries',
|
|
116
|
+
'table',
|
|
117
|
+
'*',
|
|
118
|
+
'columns',
|
|
119
|
+
'*',
|
|
120
|
+
'default',
|
|
121
|
+
'value',
|
|
122
|
+
];
|
|
123
|
+
const COLUMN_PATHS: readonly (readonly PathSegment[])[] = [
|
|
124
|
+
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*'],
|
|
125
|
+
['storage', 'namespaces', '*', 'tables', '*', 'columns', '*'],
|
|
126
|
+
];
|
|
127
|
+
const DATA_TYPE_PATHS: readonly (readonly PathSegment[])[] = [
|
|
128
|
+
...COLUMN_PATHS,
|
|
129
|
+
['storage', 'types', '*'],
|
|
130
|
+
];
|
|
131
|
+
const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
|
|
132
|
+
['domain', 'namespaces'],
|
|
133
|
+
['domain', 'namespaces', '*'],
|
|
134
|
+
['domain', 'namespaces', '*', 'models'],
|
|
135
|
+
['domain', 'namespaces', '*', 'models', '*', 'relations'],
|
|
136
|
+
['domain', 'namespaces', '*', 'models', '*', 'storage'],
|
|
137
|
+
['storage', 'namespaces'],
|
|
138
|
+
['storage', 'namespaces', '*', 'entries'],
|
|
139
|
+
['roots'],
|
|
140
|
+
['extensions'],
|
|
141
|
+
['extensions', '*'],
|
|
142
|
+
['capabilities'],
|
|
143
|
+
['meta'],
|
|
144
|
+
['execution', 'mutations', 'defaults'],
|
|
145
|
+
];
|
|
146
|
+
const TOP_LEVEL_ORDER = [
|
|
147
|
+
'schemaVersion',
|
|
148
|
+
'canonicalVersion',
|
|
149
|
+
'targetFamily',
|
|
150
|
+
'target',
|
|
151
|
+
'profileHash',
|
|
152
|
+
'roots',
|
|
153
|
+
'domain',
|
|
154
|
+
'storage',
|
|
155
|
+
'execution',
|
|
156
|
+
'capabilities',
|
|
157
|
+
'extensions',
|
|
158
|
+
'defaultControlPolicy',
|
|
159
|
+
'meta',
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
function isDefaultValue(value: Json): boolean {
|
|
163
|
+
if (value === false) return true;
|
|
164
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
165
|
+
return isRecord(value) && Object.keys(value).length === 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isPreservedEmpty(path: readonly string[], key: string): boolean {
|
|
169
|
+
if (key === 'nullable') return true;
|
|
170
|
+
if (key === 'elementNullable' && path.at(-2) === 'many') return true;
|
|
171
|
+
if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
|
|
172
|
+
if (
|
|
173
|
+
path.length >= COLUMN_DEFAULT_VALUE.length &&
|
|
174
|
+
matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
|
|
175
|
+
)
|
|
176
|
+
return true;
|
|
177
|
+
return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function omitDefaults(value: Json, path: readonly string[]): Json {
|
|
181
|
+
if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
|
|
182
|
+
if (!isRecord(value)) return value;
|
|
183
|
+
const result: JsonRecord = {};
|
|
184
|
+
for (const [key, child] of Object.entries(value)) {
|
|
185
|
+
const childPath = [...path, key];
|
|
186
|
+
if (key === '_generated') continue;
|
|
187
|
+
if (key === 'generated' && child === false) continue;
|
|
188
|
+
if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
|
|
189
|
+
if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
|
|
190
|
+
result[key] = omitDefaults(child, childPath);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function compareByName(a: Json, b: Json): number {
|
|
196
|
+
const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
|
|
197
|
+
const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
|
|
198
|
+
return compareCodeUnits(nameA, nameB);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sortTableArrays(storage: Json): Json {
|
|
202
|
+
if (!isRecord(storage)) return storage;
|
|
203
|
+
const namespaces = storage['namespaces'];
|
|
204
|
+
if (!isRecord(namespaces)) return storage;
|
|
205
|
+
const sortedNamespaces: JsonRecord = {};
|
|
206
|
+
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
|
|
207
|
+
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
|
|
208
|
+
const tables = isRecord(entries) ? entries['table'] : undefined;
|
|
209
|
+
if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
|
|
210
|
+
sortedNamespaces[namespaceId] = namespace;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const sortedTables: JsonRecord = {};
|
|
214
|
+
for (const [tableName, table] of Object.entries(tables)) {
|
|
215
|
+
if (!isRecord(table)) {
|
|
216
|
+
sortedTables[tableName] = table;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const sortedTable: JsonRecord = { ...table };
|
|
220
|
+
for (const key of ['checks', 'indexes', 'uniques']) {
|
|
221
|
+
const list = table[key];
|
|
222
|
+
if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
|
|
223
|
+
}
|
|
224
|
+
sortedTables[tableName] = sortedTable;
|
|
225
|
+
}
|
|
226
|
+
sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
|
|
227
|
+
}
|
|
228
|
+
return { ...storage, namespaces: sortedNamespaces };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function orderTopLevel(value: JsonRecord): JsonRecord {
|
|
232
|
+
const ordered: JsonRecord = {};
|
|
233
|
+
const remaining = new Set(Object.keys(value));
|
|
234
|
+
for (const key of TOP_LEVEL_ORDER) {
|
|
235
|
+
const child = value[key];
|
|
236
|
+
if (child !== undefined) {
|
|
237
|
+
ordered[key] = child;
|
|
238
|
+
remaining.delete(key);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const key of [...remaining].sort(compareCodeUnits)) {
|
|
242
|
+
const child = value[key];
|
|
243
|
+
if (child !== undefined) ordered[key] = child;
|
|
244
|
+
}
|
|
245
|
+
return ordered;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
|
|
249
|
+
const namespaces = storage['namespaces'];
|
|
250
|
+
if (!isRecord(namespaces)) return storage;
|
|
251
|
+
const stripped: JsonRecord = {};
|
|
252
|
+
for (const [namespaceId, namespace] of Object.entries(namespaces)) {
|
|
253
|
+
if (isRecord(namespace)) {
|
|
254
|
+
const { kind: _kind, ...rest } = namespace;
|
|
255
|
+
stripped[namespaceId] = rest;
|
|
256
|
+
} else {
|
|
257
|
+
stripped[namespaceId] = namespace;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { ...storage, namespaces: stripped };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function computeStorageHash(contract: JsonRecord): string {
|
|
264
|
+
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
|
|
265
|
+
const { storageHash: _published, ...withoutHash } = storage;
|
|
266
|
+
const hashed: JsonRecord = {
|
|
267
|
+
schemaVersion: '1',
|
|
268
|
+
targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
|
|
269
|
+
target: typeof contract['target'] === 'string' ? contract['target'] : '',
|
|
270
|
+
profileHash: '',
|
|
271
|
+
roots: {},
|
|
272
|
+
domain: { namespaces: {} },
|
|
273
|
+
storage: omitNamespaceKinds(withoutHash),
|
|
274
|
+
extensions: {},
|
|
275
|
+
capabilities: {},
|
|
276
|
+
meta: {},
|
|
277
|
+
};
|
|
278
|
+
const withoutDefaults = omitDefaults(hashed, []);
|
|
279
|
+
if (!isRecord(withoutDefaults)) return '';
|
|
280
|
+
const withSortedStorage = {
|
|
281
|
+
...withoutDefaults,
|
|
282
|
+
storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
|
|
283
|
+
};
|
|
284
|
+
const sorted = sortKeys(withSortedStorage);
|
|
285
|
+
return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
|
|
289
|
+
const { migrationHash: _migrationHash, ...withoutHash } = metadata;
|
|
290
|
+
const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
|
|
291
|
+
return sha256(canonicalizeJson(parts));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface ProjectFiles {
|
|
295
|
+
readonly json: readonly string[];
|
|
296
|
+
readonly migrationJson: readonly string[];
|
|
297
|
+
readonly refs: readonly string[];
|
|
298
|
+
readonly migrationTs: readonly string[];
|
|
299
|
+
readonly leftovers: readonly string[];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isDirectory(path: string): boolean {
|
|
303
|
+
try {
|
|
304
|
+
return statSync(path).isDirectory();
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isFile(path: string): boolean {
|
|
311
|
+
try {
|
|
312
|
+
return statSync(path).isFile();
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A file, or a link to one: the script reads and writes through links to files. */
|
|
319
|
+
function isFileEntry(dir: string, entry: Dirent): boolean {
|
|
320
|
+
return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** A file the script reads, by the path it was reached through: its own path rather than a link's. */
|
|
324
|
+
interface FoundFile {
|
|
325
|
+
readonly path: string;
|
|
326
|
+
readonly linked: boolean;
|
|
327
|
+
readonly inMigrationPackage: boolean;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function listFiles(root: string): ProjectFiles {
|
|
331
|
+
const leftovers: string[] = [];
|
|
332
|
+
const visited = new Set<string>();
|
|
333
|
+
const found = new Map<string, FoundFile>();
|
|
334
|
+
const visit = (dir: string): void => {
|
|
335
|
+
const realDir = realpathSync(dir);
|
|
336
|
+
if (visited.has(realDir)) return;
|
|
337
|
+
visited.add(realDir);
|
|
338
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
339
|
+
compareCodeUnits(a.name, b.name),
|
|
340
|
+
);
|
|
341
|
+
const inMigrationPackage = entries.some(
|
|
342
|
+
(entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
|
|
343
|
+
);
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
const path = join(dir, entry.name);
|
|
346
|
+
if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
|
|
347
|
+
leftovers.push(path);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
|
|
351
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name)) visit(path);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!isFileEntry(dir, entry)) continue;
|
|
355
|
+
const realFile = realpathSync(path);
|
|
356
|
+
const linked = entry.isSymbolicLink();
|
|
357
|
+
const known = found.get(realFile);
|
|
358
|
+
if (known === undefined || (known.linked && !linked))
|
|
359
|
+
found.set(realFile, { path, linked, inMigrationPackage });
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
visit(root);
|
|
363
|
+
const files = [...found.values()];
|
|
364
|
+
const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
|
|
365
|
+
files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
|
|
366
|
+
return {
|
|
367
|
+
json: named((_file, name) => name.endsWith('.json')),
|
|
368
|
+
migrationJson: named((_file, name) => name === 'migration.json'),
|
|
369
|
+
refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
|
|
370
|
+
migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
|
|
371
|
+
leftovers,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isSnapshotContract(path: string): boolean {
|
|
376
|
+
const snapshotDir = dirname(path);
|
|
377
|
+
return (
|
|
378
|
+
basename(path) === 'contract.json' &&
|
|
379
|
+
HASH.test(basename(snapshotDir)) &&
|
|
380
|
+
basename(dirname(snapshotDir)) === 'snapshots'
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function mapRecord(value: Json, map: (key: string, child: Json) => Json): Json {
|
|
385
|
+
if (!isRecord(value)) return value;
|
|
386
|
+
const result: JsonRecord = {};
|
|
387
|
+
for (const [key, child] of Object.entries(value)) result[key] = map(key, child);
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function mapEntries(
|
|
392
|
+
value: Json,
|
|
393
|
+
path: readonly string[],
|
|
394
|
+
patterns: readonly (readonly PathSegment[])[],
|
|
395
|
+
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
|
|
396
|
+
): Json {
|
|
397
|
+
if (!isRecord(value)) return value;
|
|
398
|
+
if (patterns.some((pattern) => matchesPath(path, pattern))) return rewrite(value, path);
|
|
399
|
+
const leadsToEntry = patterns.some(
|
|
400
|
+
(pattern) => path.length < pattern.length && matchesPath(path, pattern.slice(0, path.length)),
|
|
401
|
+
);
|
|
402
|
+
return leadsToEntry
|
|
403
|
+
? mapRecord(value, (key, child) => mapEntries(child, [...path, key], patterns, rewrite))
|
|
404
|
+
: value;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function mapContractEntries(
|
|
408
|
+
contract: JsonRecord,
|
|
409
|
+
patterns: readonly (readonly PathSegment[])[],
|
|
410
|
+
rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
|
|
411
|
+
): JsonRecord {
|
|
412
|
+
const result: JsonRecord = {};
|
|
413
|
+
for (const [key, child] of Object.entries(contract))
|
|
414
|
+
result[key] = mapEntries(child, [key], patterns, rewrite);
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function enumKey(namespaceId: string, name: string): string {
|
|
419
|
+
return `${namespaceId}\u0000${name}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
|
|
423
|
+
const storage = isRecord(contract['storage']) ? contract['storage'] : {};
|
|
424
|
+
return { ...contract, storage: { ...storage, storageHash: hash } };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function emittedForm(contract: JsonRecord, original: string): string {
|
|
428
|
+
const { _generated: generated, ...rest } = contract;
|
|
429
|
+
const sorted = sortKeys(rest);
|
|
430
|
+
const ordered = isRecord(sorted) ? orderTopLevel(sorted) : {};
|
|
431
|
+
if (generated !== undefined) ordered['_generated'] = generated;
|
|
432
|
+
return `${JSON.stringify(ordered, null, 2)}${original.endsWith('\n') ? '\n' : ''}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function snapshotForm(contract: JsonRecord): string {
|
|
436
|
+
return `${canonicalizeJson(contract)}\n`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
interface Block {
|
|
440
|
+
readonly start: number;
|
|
441
|
+
end: number;
|
|
442
|
+
readonly label: string;
|
|
443
|
+
readonly parent: Block | undefined;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function scanBlocks(text: string): Block[] {
|
|
447
|
+
const blocks: Block[] = [];
|
|
448
|
+
const open: Block[] = [];
|
|
449
|
+
let quote: string | undefined;
|
|
450
|
+
for (let index = 0; index < text.length; index++) {
|
|
451
|
+
const char = text[index];
|
|
452
|
+
if (quote !== undefined) {
|
|
453
|
+
if (char === '\\') index++;
|
|
454
|
+
else if (char === quote) quote = undefined;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
458
|
+
quote = char;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (char === '{') {
|
|
462
|
+
const key =
|
|
463
|
+
/(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
|
|
464
|
+
text.slice(Math.max(0, index - 200), index),
|
|
465
|
+
);
|
|
466
|
+
const block: Block = {
|
|
467
|
+
start: index,
|
|
468
|
+
end: text.length,
|
|
469
|
+
label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
|
|
470
|
+
parent: open.at(-1),
|
|
471
|
+
};
|
|
472
|
+
open.push(block);
|
|
473
|
+
blocks.push(block);
|
|
474
|
+
} else if (char === '}') {
|
|
475
|
+
const block = open.pop();
|
|
476
|
+
if (block !== undefined) block.end = index;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return blocks;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
|
|
483
|
+
let found: Block | undefined;
|
|
484
|
+
for (const block of blocks) {
|
|
485
|
+
if (
|
|
486
|
+
block.start < position &&
|
|
487
|
+
position < block.end &&
|
|
488
|
+
(found === undefined || block.start > found.start)
|
|
489
|
+
)
|
|
490
|
+
found = block;
|
|
491
|
+
}
|
|
492
|
+
return found;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isColumnBlock(block: Block): boolean {
|
|
496
|
+
return block.parent?.label === 'columns';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function isStorageTypeBlock(blocks: readonly Block[], block: Block): boolean {
|
|
500
|
+
const types = block.parent;
|
|
501
|
+
return (
|
|
502
|
+
types?.label === 'types' &&
|
|
503
|
+
blocks.some((other) => other.parent === types.parent && other.label === 'namespaces')
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isInColumnDefault(block: Block): boolean {
|
|
508
|
+
for (let ancestor = block.parent; ancestor !== undefined; ancestor = ancestor.parent)
|
|
509
|
+
if (
|
|
510
|
+
ancestor.label === 'default' &&
|
|
511
|
+
ancestor.parent !== undefined &&
|
|
512
|
+
isColumnBlock(ancestor.parent)
|
|
513
|
+
)
|
|
514
|
+
return true;
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function holdsDataType(blocks: readonly Block[], block: Block): boolean {
|
|
519
|
+
return (isColumnBlock(block) || isStorageTypeBlock(blocks, block)) && !isInColumnDefault(block);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function genericArgumentEnd(text: string, start: number): number {
|
|
523
|
+
let depth = 0;
|
|
524
|
+
let quote: string | undefined;
|
|
525
|
+
for (let index = start; index < text.length; index++) {
|
|
526
|
+
const char = text[index];
|
|
527
|
+
if (quote !== undefined) {
|
|
528
|
+
if (char === '\\') index++;
|
|
529
|
+
else if (char === quote) quote = undefined;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (char === "'" || char === '"' || char === '`') quote = char;
|
|
533
|
+
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
|
|
534
|
+
else if (char === '}' || char === ']' || char === ')') depth--;
|
|
535
|
+
else if (char === '>') {
|
|
536
|
+
if (depth === 0) return index;
|
|
537
|
+
depth--;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return text.length;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function memberOf(
|
|
544
|
+
text: string,
|
|
545
|
+
blocks: readonly Block[],
|
|
546
|
+
block: Block,
|
|
547
|
+
name: string,
|
|
548
|
+
): string | undefined {
|
|
549
|
+
const pattern = new RegExp(`readonly ${name}: (['"])((?:\\\\.|(?!\\1).)*)\\1`, 'g');
|
|
550
|
+
for (const member of text.slice(block.start, block.end).matchAll(pattern)) {
|
|
551
|
+
if (innermostBlock(blocks, block.start + member.index) === block) return member[2];
|
|
552
|
+
}
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The end of the type literal that starts at `start`: the first `;`, `,` or closing bracket outside it. */
|
|
557
|
+
function literalEnd(text: string, start: number): number {
|
|
558
|
+
let depth = 0;
|
|
559
|
+
let quote: string | undefined;
|
|
560
|
+
for (let index = start; index < text.length; index++) {
|
|
561
|
+
const char = text[index];
|
|
562
|
+
if (quote !== undefined) {
|
|
563
|
+
if (char === '\\') index++;
|
|
564
|
+
else if (char === quote) quote = undefined;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (char === "'" || char === '"' || char === '`') quote = char;
|
|
568
|
+
else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
|
|
569
|
+
else if (char === '}' || char === ']' || char === ')' || char === '>') {
|
|
570
|
+
if (depth === 0) return index;
|
|
571
|
+
depth--;
|
|
572
|
+
} else if ((char === ';' || char === ',') && depth === 0) return index;
|
|
573
|
+
}
|
|
574
|
+
return text.length;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function bracketEnd(text: string, open: number): number {
|
|
578
|
+
let depth = 0;
|
|
579
|
+
let quote: string | undefined;
|
|
580
|
+
for (let index = open; index < text.length; index++) {
|
|
581
|
+
const char = text[index];
|
|
582
|
+
if (quote !== undefined) {
|
|
583
|
+
if (char === '\\') index++;
|
|
584
|
+
else if (char === quote) quote = undefined;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (char === "'" || char === '"' || char === '`') quote = char;
|
|
588
|
+
else if (char === '[' || char === '{' || char === '(') depth++;
|
|
589
|
+
else if (char === ']' || char === '}' || char === ')') {
|
|
590
|
+
depth--;
|
|
591
|
+
if (depth === 0) return index + 1;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return text.length;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
|
|
598
|
+
return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
|
|
599
|
+
const next = hashes.get(hash);
|
|
600
|
+
return next === undefined ? match : `"${next}"`;
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function readDirectory(dir: string): Map<string, string> {
|
|
605
|
+
const files = new Map<string, string>();
|
|
606
|
+
if (!existsSync(dir)) return files;
|
|
607
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }))
|
|
608
|
+
if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
|
|
609
|
+
files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
|
|
610
|
+
return files;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
|
|
614
|
+
return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function temporaryPath(path: string): string {
|
|
618
|
+
return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function writeWithModeOf(path: string, text: string, modeSource: string): void {
|
|
622
|
+
writeFileSync(path, text);
|
|
623
|
+
if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function writeFile(path: string, text: string): void {
|
|
627
|
+
const target = realpathSync(path);
|
|
628
|
+
const temporary = temporaryPath(target);
|
|
629
|
+
writeWithModeOf(temporary, text, target);
|
|
630
|
+
renameSync(temporary, target);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
|
|
634
|
+
const temporary = temporaryPath(dir);
|
|
635
|
+
mkdirSync(temporary);
|
|
636
|
+
for (const [name, text] of files)
|
|
637
|
+
writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
|
|
638
|
+
renameSync(temporary, dir);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function removeDirectory(dir: string): void {
|
|
642
|
+
const temporary = temporaryPath(dir);
|
|
643
|
+
renameSync(dir, temporary);
|
|
644
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function count(amount: number, singular: string, plural: string): string {
|
|
648
|
+
return `${amount} ${amount === 1 ? singular : plural}`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function summary(
|
|
652
|
+
root: string,
|
|
653
|
+
contractsFound: number,
|
|
654
|
+
files: number,
|
|
655
|
+
directories: number,
|
|
656
|
+
hashes: ReadonlyMap<string, string>,
|
|
657
|
+
): readonly string[] {
|
|
658
|
+
if (contractsFound === 0) return [`No SQLite contract was found under ${root}; nothing changed.`];
|
|
659
|
+
if (files === 0 && directories === 0)
|
|
660
|
+
return ['The project is already in the new form; nothing changed.'];
|
|
661
|
+
const renamed = count(directories, 'snapshot directory', 'snapshot directories');
|
|
662
|
+
const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
|
|
663
|
+
if (hashes.size === 0) return [changes];
|
|
664
|
+
return [
|
|
665
|
+
`${changes} Storage hashes changed (old -> new):`,
|
|
666
|
+
...[...hashes]
|
|
667
|
+
.sort(([a], [b]) => compareCodeUnits(a, b))
|
|
668
|
+
.map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
|
|
669
|
+
];
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** The text `sqlite/datetime` stores for an instant the previous release stored: three fraction digits, always. */
|
|
673
|
+
function storedDatetime(value: Json): Json {
|
|
674
|
+
if (typeof value !== 'string') return value;
|
|
675
|
+
const match = UTC_INSTANT.exec(value);
|
|
676
|
+
return match === null ? value : `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function storedDatetimes(value: Json): Json {
|
|
680
|
+
return Array.isArray(value) ? value.map(storedDatetime) : storedDatetime(value);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
interface Rewrite {
|
|
684
|
+
readonly contract: JsonRecord;
|
|
685
|
+
readonly changed: boolean;
|
|
686
|
+
readonly datetimeEnums: ReadonlySet<string>;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* A `sqlite/datetime@1` enum's members in the domain, and the storage value set its columns name,
|
|
691
|
+
* hold instants, so they are written as `sqlite/datetime` stores them too.
|
|
692
|
+
*/
|
|
693
|
+
function rewriteDatetimeEnums(contract: JsonRecord): Rewrite {
|
|
694
|
+
const datetimeEnums = new Set<string>();
|
|
695
|
+
let changed = false;
|
|
696
|
+
const rewritten = (values: readonly Json[]): Json[] => {
|
|
697
|
+
const next = values.map(storedDatetime);
|
|
698
|
+
if (canonicalizeJson(next) !== canonicalizeJson([...values])) changed = true;
|
|
699
|
+
return next;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const domain = mapRecord(contract['domain'] ?? null, (key, namespaces) =>
|
|
703
|
+
key !== 'namespaces'
|
|
704
|
+
? namespaces
|
|
705
|
+
: mapRecord(namespaces, (namespaceId, namespace) =>
|
|
706
|
+
mapRecord(namespace, (kind, enums) =>
|
|
707
|
+
kind !== 'enum'
|
|
708
|
+
? enums
|
|
709
|
+
: mapRecord(enums, (name, enumType) => {
|
|
710
|
+
const members = isRecord(enumType) ? enumType['members'] : undefined;
|
|
711
|
+
if (
|
|
712
|
+
!isRecord(enumType) ||
|
|
713
|
+
enumType['codecId'] !== DATETIME_CODEC ||
|
|
714
|
+
!Array.isArray(members)
|
|
715
|
+
)
|
|
716
|
+
return enumType;
|
|
717
|
+
datetimeEnums.add(enumKey(namespaceId, name));
|
|
718
|
+
const values = rewritten(
|
|
719
|
+
members.map((member) => (isRecord(member) ? (member['value'] ?? null) : null)),
|
|
720
|
+
);
|
|
721
|
+
return {
|
|
722
|
+
...enumType,
|
|
723
|
+
members: members.map((member, index) =>
|
|
724
|
+
isRecord(member) && member['value'] !== undefined
|
|
725
|
+
? { ...member, value: values[index] ?? null }
|
|
726
|
+
: member,
|
|
727
|
+
),
|
|
728
|
+
};
|
|
729
|
+
}),
|
|
730
|
+
),
|
|
731
|
+
),
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const storage = contract['storage'];
|
|
735
|
+
const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
|
|
736
|
+
for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
|
|
737
|
+
const entries = isRecord(namespace) ? namespace['entries'] : undefined;
|
|
738
|
+
const tables = isRecord(entries) ? entries['table'] : undefined;
|
|
739
|
+
for (const table of isRecord(tables) ? Object.values(tables) : []) {
|
|
740
|
+
const columns = isRecord(table) ? table['columns'] : undefined;
|
|
741
|
+
for (const column of isRecord(columns) ? Object.values(columns) : []) {
|
|
742
|
+
const valueSet = isRecord(column) ? column['valueSet'] : undefined;
|
|
743
|
+
if (
|
|
744
|
+
isRecord(column) &&
|
|
745
|
+
column['codecId'] === DATETIME_CODEC &&
|
|
746
|
+
isRecord(valueSet) &&
|
|
747
|
+
typeof valueSet['namespaceId'] === 'string' &&
|
|
748
|
+
typeof valueSet['entityName'] === 'string'
|
|
749
|
+
)
|
|
750
|
+
datetimeEnums.add(enumKey(valueSet['namespaceId'], valueSet['entityName']));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const nextStorage = mapRecord(storage ?? null, (key, storageNamespaces) =>
|
|
756
|
+
key !== 'namespaces'
|
|
757
|
+
? storageNamespaces
|
|
758
|
+
: mapRecord(storageNamespaces, (namespaceId, namespace) =>
|
|
759
|
+
mapRecord(namespace, (namespaceKey, entries) =>
|
|
760
|
+
namespaceKey !== 'entries'
|
|
761
|
+
? entries
|
|
762
|
+
: mapRecord(entries, (entryKind, valueSets) =>
|
|
763
|
+
entryKind !== 'valueSet'
|
|
764
|
+
? valueSets
|
|
765
|
+
: mapRecord(valueSets, (name, valueSet) => {
|
|
766
|
+
const values = isRecord(valueSet) ? valueSet['values'] : undefined;
|
|
767
|
+
if (
|
|
768
|
+
!datetimeEnums.has(enumKey(namespaceId, name)) ||
|
|
769
|
+
!isRecord(valueSet) ||
|
|
770
|
+
!Array.isArray(values)
|
|
771
|
+
)
|
|
772
|
+
return valueSet;
|
|
773
|
+
return { ...valueSet, values: rewritten(values) };
|
|
774
|
+
}),
|
|
775
|
+
),
|
|
776
|
+
),
|
|
777
|
+
),
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
contract: {
|
|
782
|
+
...contract,
|
|
783
|
+
...(contract['domain'] === undefined ? {} : { domain }),
|
|
784
|
+
...(storage === undefined ? {} : { storage: nextStorage }),
|
|
785
|
+
},
|
|
786
|
+
changed,
|
|
787
|
+
datetimeEnums,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function rewriteContract(contract: JsonRecord): Rewrite {
|
|
792
|
+
if (contract['target'] !== 'sqlite')
|
|
793
|
+
return { contract, changed: false, datetimeEnums: new Set() };
|
|
794
|
+
let changed = false;
|
|
795
|
+
const rewriteEntry = (entry: JsonRecord): JsonRecord => {
|
|
796
|
+
const codecId = entry['codecId'];
|
|
797
|
+
const dataType = typeof codecId === 'string' ? DATA_TYPES[codecId] : undefined;
|
|
798
|
+
if (dataType === undefined) return entry;
|
|
799
|
+
let next = entry;
|
|
800
|
+
if (entry['dataType'] === PREVIOUS_DATA_TYPE) {
|
|
801
|
+
next = { ...next, dataType };
|
|
802
|
+
changed = true;
|
|
803
|
+
}
|
|
804
|
+
const columnDefault = next['default'];
|
|
805
|
+
if (
|
|
806
|
+
codecId === DATETIME_CODEC &&
|
|
807
|
+
isRecord(columnDefault) &&
|
|
808
|
+
columnDefault['kind'] === 'literal' &&
|
|
809
|
+
columnDefault['value'] !== undefined
|
|
810
|
+
) {
|
|
811
|
+
const value = storedDatetimes(columnDefault['value']);
|
|
812
|
+
if (canonicalizeJson(value) !== canonicalizeJson(columnDefault['value'])) {
|
|
813
|
+
next = { ...next, default: { ...columnDefault, value } };
|
|
814
|
+
changed = true;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return next;
|
|
818
|
+
};
|
|
819
|
+
const enums = rewriteDatetimeEnums(mapContractEntries(contract, DATA_TYPE_PATHS, rewriteEntry));
|
|
820
|
+
return {
|
|
821
|
+
contract: enums.contract,
|
|
822
|
+
changed: changed || enums.changed,
|
|
823
|
+
datetimeEnums: enums.datetimeEnums,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
interface Edit {
|
|
828
|
+
readonly start: number;
|
|
829
|
+
readonly end: number;
|
|
830
|
+
readonly text: string;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** The quoted instants between `start` and `end`, each written as `sqlite/datetime` stores it. */
|
|
834
|
+
function datetimeEdits(text: string, start: number, end: number): Edit[] {
|
|
835
|
+
const edits: Edit[] = [];
|
|
836
|
+
for (const match of text.slice(start, end).matchAll(/(['"])((?:\\.|(?!\1).)*)\1/g)) {
|
|
837
|
+
const value = match[2] ?? '';
|
|
838
|
+
const stored = storedDatetime(value);
|
|
839
|
+
if (stored === value) continue;
|
|
840
|
+
const at = start + match.index + 1;
|
|
841
|
+
edits.push({ start: at, end: at + value.length, text: String(stored) });
|
|
842
|
+
}
|
|
843
|
+
return edits;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function rewriteDts(
|
|
847
|
+
text: string,
|
|
848
|
+
datetimeEnums: ReadonlySet<string>,
|
|
849
|
+
hashes: ReadonlyMap<string, string>,
|
|
850
|
+
): string {
|
|
851
|
+
const blocks = scanBlocks(text);
|
|
852
|
+
const edits: Edit[] = [];
|
|
853
|
+
|
|
854
|
+
for (const match of text.matchAll(/readonly dataType: (['"])sqlite\/text\1/g)) {
|
|
855
|
+
const block = innermostBlock(blocks, match.index);
|
|
856
|
+
if (block === undefined || !holdsDataType(blocks, block)) continue;
|
|
857
|
+
const codecId = memberOf(text, blocks, block, 'codecId');
|
|
858
|
+
const dataType = codecId === undefined ? undefined : DATA_TYPES[codecId];
|
|
859
|
+
if (dataType === undefined) continue;
|
|
860
|
+
const quote = match[1] ?? "'";
|
|
861
|
+
edits.push({
|
|
862
|
+
start: match.index,
|
|
863
|
+
end: match.index + match[0].length,
|
|
864
|
+
text: `readonly dataType: ${quote}${dataType}${quote}`,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
for (const match of text.matchAll(/DefaultLiteralValue<\s*(['"])sqlite\/datetime@1\1\s*,\s*/g)) {
|
|
869
|
+
const start = match.index + match[0].length;
|
|
870
|
+
edits.push(...datetimeEdits(text, start, genericArgumentEnd(text, start)));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
for (const match of text.matchAll(/readonly values: readonly \[/g)) {
|
|
874
|
+
const valueSet = innermostBlock(blocks, match.index);
|
|
875
|
+
const kind = valueSet?.parent;
|
|
876
|
+
const namespace = kind?.parent?.parent;
|
|
877
|
+
if (
|
|
878
|
+
valueSet === undefined ||
|
|
879
|
+
kind?.label !== 'valueSet' ||
|
|
880
|
+
kind.parent?.label !== 'entries' ||
|
|
881
|
+
namespace === undefined ||
|
|
882
|
+
!datetimeEnums.has(enumKey(namespace.label, valueSet.label))
|
|
883
|
+
)
|
|
884
|
+
continue;
|
|
885
|
+
const open = match.index + match[0].length - 1;
|
|
886
|
+
edits.push(...datetimeEdits(text, open, bracketEnd(text, open)));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
for (const match of text.matchAll(/readonly value: /g)) {
|
|
890
|
+
const member = innermostBlock(blocks, match.index);
|
|
891
|
+
const enumType = member?.parent;
|
|
892
|
+
const namespace = enumType?.parent?.parent;
|
|
893
|
+
if (
|
|
894
|
+
member === undefined ||
|
|
895
|
+
member.label !== '' ||
|
|
896
|
+
enumType?.parent?.label !== 'enum' ||
|
|
897
|
+
namespace === undefined ||
|
|
898
|
+
!datetimeEnums.has(enumKey(namespace.label, enumType.label))
|
|
899
|
+
)
|
|
900
|
+
continue;
|
|
901
|
+
const start = match.index + match[0].length;
|
|
902
|
+
edits.push(...datetimeEdits(text, start, literalEnd(text, start)));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
for (const match of text.matchAll(/(['"])([0-9a-f]{64})\1/g)) {
|
|
906
|
+
const hash = match[2];
|
|
907
|
+
const next = hash === undefined ? undefined : hashes.get(hash);
|
|
908
|
+
if (next === undefined) continue;
|
|
909
|
+
edits.push({
|
|
910
|
+
start: match.index,
|
|
911
|
+
end: match.index + match[0].length,
|
|
912
|
+
text: `${match[1]}${next}${match[1]}`,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
let result = text;
|
|
917
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
918
|
+
result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
|
|
919
|
+
return result;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
interface ContractPlan {
|
|
923
|
+
readonly path: string;
|
|
924
|
+
readonly snapshot: boolean;
|
|
925
|
+
readonly oldHash: string;
|
|
926
|
+
readonly newHash: string;
|
|
927
|
+
readonly content: string;
|
|
928
|
+
readonly datetimeEnums: ReadonlySet<string>;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
|
|
932
|
+
|
|
933
|
+
function parseRoot(args: readonly string[]): { readonly root: string; readonly errors: string[] } {
|
|
934
|
+
const errors = args.filter((arg) => arg.startsWith('--')).map((arg) => `${arg}: unknown option`);
|
|
935
|
+
const positional = args.filter((arg) => !arg.startsWith('--'));
|
|
936
|
+
if (positional.length > 1) errors.push(`expected one project root, got ${positional.length}`);
|
|
937
|
+
return { root: resolve(positional[0] ?? process.cwd()), errors };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function main({
|
|
941
|
+
root,
|
|
942
|
+
errors,
|
|
943
|
+
}: {
|
|
944
|
+
readonly root: string;
|
|
945
|
+
readonly errors: readonly string[];
|
|
946
|
+
}): number {
|
|
947
|
+
if (errors.length > 0) {
|
|
948
|
+
process.stderr.write(`${errors.join('\n')}\n`);
|
|
949
|
+
return 1;
|
|
950
|
+
}
|
|
951
|
+
const display = (path: string): string => relative(root, path).split(sep).join('/');
|
|
952
|
+
const files = listFiles(root);
|
|
953
|
+
const plans: ContractPlan[] = [];
|
|
954
|
+
const notices: string[] = [];
|
|
955
|
+
const stops: string[] = [];
|
|
956
|
+
let contractsFound = 0;
|
|
957
|
+
|
|
958
|
+
for (const path of files.json) {
|
|
959
|
+
const text = readFileSync(path, 'utf8');
|
|
960
|
+
const contract = parseJson(text);
|
|
961
|
+
if (
|
|
962
|
+
contract === undefined &&
|
|
963
|
+
(JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
|
|
964
|
+
) {
|
|
965
|
+
stops.push(`${display(path)}: not valid JSON`);
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
if (
|
|
969
|
+
!isRecord(contract) ||
|
|
970
|
+
contract['targetFamily'] !== 'sql' ||
|
|
971
|
+
contract['target'] !== 'sqlite' ||
|
|
972
|
+
!isRecord(contract['storage'])
|
|
973
|
+
)
|
|
974
|
+
continue;
|
|
975
|
+
contractsFound++;
|
|
976
|
+
const snapshot = isSnapshotContract(path);
|
|
977
|
+
const stored = contract['storage']['storageHash'];
|
|
978
|
+
const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
|
|
979
|
+
const rewrite = rewriteContract(contract);
|
|
980
|
+
if (!rewrite.changed) continue;
|
|
981
|
+
if (computeStorageHash(contract) !== oldHash)
|
|
982
|
+
notices.push(`${display(path)}: stored hash did not recompute; rehashed from content`);
|
|
983
|
+
const newHash = computeStorageHash(rewrite.contract);
|
|
984
|
+
const contractWithHash = withStorageHash(rewrite.contract, newHash);
|
|
985
|
+
plans.push({
|
|
986
|
+
path,
|
|
987
|
+
snapshot,
|
|
988
|
+
oldHash,
|
|
989
|
+
newHash,
|
|
990
|
+
content: snapshot ? snapshotForm(contractWithHash) : emittedForm(contractWithHash, text),
|
|
991
|
+
datetimeEnums: rewrite.datetimeEnums,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const hashes = new Map<string, string>();
|
|
996
|
+
for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
|
|
997
|
+
if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
|
|
998
|
+
hashes.set(plan.oldHash, plan.newHash);
|
|
999
|
+
|
|
1000
|
+
const newDirectories = new Map<string, Map<string, string>>();
|
|
1001
|
+
const directorySources = new Map<string, string>();
|
|
1002
|
+
const sourceDirectories = new Set<string>();
|
|
1003
|
+
const removals = new Set<string>();
|
|
1004
|
+
const snapshotRewrites: [string, string][] = [];
|
|
1005
|
+
const referenceWrites = new Map<string, string>();
|
|
1006
|
+
const contractWrites = new Map<string, string>();
|
|
1007
|
+
for (const plan of plans) {
|
|
1008
|
+
const dtsPath = plan.snapshot
|
|
1009
|
+
? join(dirname(plan.path), 'contract.d.ts')
|
|
1010
|
+
: `${plan.path.slice(0, -'.json'.length)}.d.ts`;
|
|
1011
|
+
const dtsHashes = plan.snapshot ? hashes : new Map([...hashes, [plan.oldHash, plan.newHash]]);
|
|
1012
|
+
const dts = existsSync(dtsPath)
|
|
1013
|
+
? rewriteDts(readFileSync(dtsPath, 'utf8'), plan.datetimeEnums, dtsHashes)
|
|
1014
|
+
: undefined;
|
|
1015
|
+
if (!plan.snapshot) {
|
|
1016
|
+
if (dts !== undefined) referenceWrites.set(dtsPath, dts);
|
|
1017
|
+
contractWrites.set(plan.path, plan.content);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const oldDir = dirname(plan.path);
|
|
1021
|
+
const newDir = join(dirname(oldDir), plan.newHash);
|
|
1022
|
+
sourceDirectories.add(oldDir);
|
|
1023
|
+
if (newDir === oldDir) {
|
|
1024
|
+
if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
|
|
1025
|
+
snapshotRewrites.push([plan.path, plan.content]);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
removals.add(oldDir);
|
|
1029
|
+
const content = readDirectory(oldDir);
|
|
1030
|
+
content.set('contract.json', plan.content);
|
|
1031
|
+
if (dts !== undefined) content.set('contract.d.ts', dts);
|
|
1032
|
+
const planned = newDirectories.get(newDir);
|
|
1033
|
+
if (planned !== undefined && !sameFiles(planned, content)) {
|
|
1034
|
+
stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
newDirectories.set(newDir, content);
|
|
1038
|
+
directorySources.set(newDir, oldDir);
|
|
1039
|
+
}
|
|
1040
|
+
for (const [dir, content] of newDirectories) {
|
|
1041
|
+
if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
|
|
1042
|
+
if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
|
|
1043
|
+
else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
for (const path of files.migrationJson) {
|
|
1047
|
+
const text = readFileSync(path, 'utf8');
|
|
1048
|
+
const metadata = parseJson(text);
|
|
1049
|
+
if (!isRecord(metadata)) continue;
|
|
1050
|
+
const from = metadata['from'];
|
|
1051
|
+
const to = metadata['to'];
|
|
1052
|
+
const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
|
|
1053
|
+
const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
|
|
1054
|
+
if (nextFrom === from && nextTo === to) continue;
|
|
1055
|
+
const opsPath = join(dirname(path), 'ops.json');
|
|
1056
|
+
const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
|
|
1057
|
+
if (existsSync(opsPath) && ops === undefined) {
|
|
1058
|
+
stops.push(`${display(opsPath)}: not valid JSON`);
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const oldMigrationHash = metadata['migrationHash'];
|
|
1062
|
+
let next = replaceQuotedHashes(text, hashes);
|
|
1063
|
+
if (
|
|
1064
|
+
ops !== undefined &&
|
|
1065
|
+
typeof oldMigrationHash === 'string' &&
|
|
1066
|
+
nextFrom !== undefined &&
|
|
1067
|
+
nextTo !== undefined
|
|
1068
|
+
) {
|
|
1069
|
+
const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
|
|
1070
|
+
next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
|
|
1071
|
+
}
|
|
1072
|
+
referenceWrites.set(path, next);
|
|
1073
|
+
}
|
|
1074
|
+
for (const path of files.refs) {
|
|
1075
|
+
const text = readFileSync(path, 'utf8');
|
|
1076
|
+
const next = replaceQuotedHashes(text, hashes);
|
|
1077
|
+
if (next !== text) referenceWrites.set(path, next);
|
|
1078
|
+
}
|
|
1079
|
+
for (const path of files.migrationTs) {
|
|
1080
|
+
const text = readFileSync(path, 'utf8');
|
|
1081
|
+
const next = text.replace(
|
|
1082
|
+
/(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
|
|
1083
|
+
(hash: string) => hashes.get(hash) ?? hash,
|
|
1084
|
+
);
|
|
1085
|
+
if (next !== text) referenceWrites.set(path, next);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (stops.length > 0) {
|
|
1089
|
+
process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
|
|
1090
|
+
return 1;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
try {
|
|
1094
|
+
for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
|
|
1095
|
+
for (const [dir, content] of newDirectories)
|
|
1096
|
+
writeDirectory(dir, content, directorySources.get(dir) ?? dir);
|
|
1097
|
+
for (const [path, text] of snapshotRewrites) writeFile(path, text);
|
|
1098
|
+
for (const [path, text] of referenceWrites) writeFile(path, text);
|
|
1099
|
+
for (const [path, text] of contractWrites) writeFile(path, text);
|
|
1100
|
+
for (const dir of removals) removeDirectory(dir);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1103
|
+
process.stderr.write(
|
|
1104
|
+
`${message}; the upgrade stopped partway, run the script again to finish it\n`,
|
|
1105
|
+
);
|
|
1106
|
+
return 1;
|
|
1107
|
+
}
|
|
1108
|
+
const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
|
|
1109
|
+
const lines = [
|
|
1110
|
+
...notices,
|
|
1111
|
+
...summary(root, contractsFound, rewrittenFiles, removals.size, hashes),
|
|
1112
|
+
];
|
|
1113
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
1114
|
+
return 0;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
process.exitCode = main(parseRoot(process.argv.slice(2)));
|