@prisma/orm-mongo 8.0.0-rc.14-dev.46 → 8.0.0-rc.15-dev.1

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.
@@ -0,0 +1,1492 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Rewrites every SQL contract in a project from `nativeType` to `dataType`, and a list stored as
4
+ * `many: true` to `many: { elementNullable: false }`, then renames the snapshot directories and
5
+ * rewrites every migration, ref, `migration.ts` and `contract.d.ts` that names an old storage hash.
6
+ *
7
+ * Usage: node data-type-in-contract.ts [project-root] [--data-type <codec id>=<data type id>]...
8
+ *
9
+ * `--data-type` names the data type of a codec the script's table does not know for a contract's
10
+ * target, such as an extension's own codec. It cannot change the data type of a codec the table
11
+ * knows for that target.
12
+ *
13
+ * The project root defaults to the working directory. The script reads and writes files only. It
14
+ * needs no database, network or configured stack, and a project already in the new format is left
15
+ * unchanged. When it stops (an unknown codec, or a snapshot directory that already exists with
16
+ * different content) it changes no file, prints one line per case and exits 1. When it finishes it
17
+ * prints how many files and snapshot directories it changed and each storage hash it 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, Readonly<Record<string, string>>>> = {
44
+ postgres: {
45
+ 'pg/text@1': 'pg/text',
46
+ 'pg/text-array@1': 'pg/text-array',
47
+ 'pg/enum@1': 'pg/enum',
48
+ 'pg/char@1': 'pg/char',
49
+ 'pg/varchar@1': 'pg/varchar',
50
+ 'pg/int@1': 'pg/int4',
51
+ 'pg/int2@1': 'pg/int2',
52
+ 'pg/int4@1': 'pg/int4',
53
+ 'pg/int8@1': 'pg/int8',
54
+ 'pg/int8number@1': 'pg/int8',
55
+ 'pg/float@1': 'pg/float8',
56
+ 'pg/float4@1': 'pg/float4',
57
+ 'pg/float8@1': 'pg/float8',
58
+ 'pg/numeric@1': 'pg/numeric',
59
+ 'pg/unboundedint@1': 'pg/numeric',
60
+ 'pg/bool@1': 'pg/bool',
61
+ 'pg/bit@1': 'pg/bit',
62
+ 'pg/varbit@1': 'pg/varbit',
63
+ 'pg/bytea@1': 'pg/bytea',
64
+ 'pg/uuid@1': 'pg/uuid',
65
+ 'pg/inet@1': 'pg/inet',
66
+ 'pg/tsquery@1': 'pg/tsquery',
67
+ 'pg/interval@1': 'pg/interval',
68
+ 'pg/json@1': 'pg/json',
69
+ 'pg/jsonb@1': 'pg/jsonb',
70
+ 'pg/timetz@1': 'pg/timetz',
71
+ 'pg/date-temporal@1': 'pg/date',
72
+ 'pg/timestamp-temporal@1': 'pg/timestamp',
73
+ 'pg/timestamptz-temporal@1': 'pg/timestamptz',
74
+ 'pg/time-temporal@1': 'pg/time',
75
+ 'pg/date-string@1': 'pg/date',
76
+ 'pg/timestamp-string@1': 'pg/timestamp',
77
+ 'pg/timestamptz-string@1': 'pg/timestamptz',
78
+ 'pg/time-string@1': 'pg/time',
79
+ 'pg/timestamptz-date@1': 'pg/timestamptz',
80
+ 'pg/timestamptz@1': 'pg/timestamptz',
81
+ 'pg/timestamp@1': 'pg/timestamp',
82
+ 'pg/time@1': 'pg/time',
83
+ 'pg/date@1': 'pg/date',
84
+ 'sql/char@1': 'pg/char',
85
+ 'sql/varchar@1': 'pg/varchar',
86
+ 'sql/int@1': 'pg/int4',
87
+ 'sql/float@1': 'pg/float8',
88
+ 'sql/text@1': 'pg/text',
89
+ 'sql/timestamp@1': 'pg/timestamptz',
90
+ 'pg/vector@1': 'pgvector/vector',
91
+ 'pg/geometry@1': 'postgis/geometry',
92
+ 'arktype/json@1': 'pg/jsonb',
93
+ },
94
+ sqlite: {
95
+ 'sqlite/text@1': 'sqlite/text',
96
+ 'sqlite/json@1': 'sqlite/text',
97
+ 'sqlite/datetime@1': 'sqlite/text',
98
+ 'sqlite/integer@1': 'sqlite/integer',
99
+ 'sqlite/bigint@1': 'sqlite/integer',
100
+ 'sqlite/bigintnumber@1': 'sqlite/integer',
101
+ 'sql/int@1': 'sqlite/integer',
102
+ 'sqlite/real@1': 'sqlite/real',
103
+ 'sql/float@1': 'sqlite/real',
104
+ 'sqlite/blob@1': 'sqlite/blob',
105
+ 'sql/char@1': 'sqlite/character',
106
+ 'sql/varchar@1': 'sqlite/character-varying',
107
+ },
108
+ };
109
+
110
+ const SQLITE_INTEGER_CODECS = new Set([
111
+ 'sqlite/integer@1',
112
+ 'sql/int@1',
113
+ 'sqlite/bigint@1',
114
+ 'sqlite/bigintnumber@1',
115
+ ]);
116
+ const SQLITE_JSON_CODEC = 'sqlite/json@1';
117
+ const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build']);
118
+ const HASH = /^[0-9a-f]{64}$/;
119
+ const TEMPORARY_SUFFIX = '.data-type-in-contract-tmp';
120
+
121
+ function isRecord(value: unknown): value is JsonRecord {
122
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
123
+ const prototype: unknown = Object.getPrototypeOf(value);
124
+ return prototype === Object.prototype || prototype === null;
125
+ }
126
+
127
+ function parseJson(text: string): Json | undefined {
128
+ try {
129
+ const value: Json = JSON.parse(text);
130
+ return value;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ function sha256(text: string): string {
137
+ return createHash('sha256').update(text).digest('hex');
138
+ }
139
+
140
+ function compareCodeUnits(a: string, b: string): number {
141
+ return a < b ? -1 : a > b ? 1 : 0;
142
+ }
143
+
144
+ function sortKeys(value: Json): Json {
145
+ if (Array.isArray(value)) return value.map(sortKeys);
146
+ if (!isRecord(value)) return value;
147
+ const sorted: JsonRecord = {};
148
+ for (const key of Object.keys(value).sort(compareCodeUnits)) {
149
+ const child = value[key];
150
+ if (child !== undefined) sorted[key] = sortKeys(child);
151
+ }
152
+ return sorted;
153
+ }
154
+
155
+ function canonicalizeJson(value: Json): string {
156
+ return JSON.stringify(sortKeys(value));
157
+ }
158
+
159
+ type PathSegment = string | readonly string[];
160
+
161
+ function matchesPath(path: readonly string[], pattern: readonly PathSegment[]): boolean {
162
+ if (path.length !== pattern.length) return false;
163
+ return pattern.every((segment, index) => {
164
+ const value = path[index];
165
+ if (value === undefined) return false;
166
+ if (segment === '*') return true;
167
+ return typeof segment === 'string' ? value === segment : segment.includes(value);
168
+ });
169
+ }
170
+
171
+ const SQL_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
172
+ ['storage', 'namespaces', '*', 'entries', 'table'],
173
+ ['storage', 'namespaces', '*', 'entries', 'table', '*'],
174
+ ['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
175
+ ['storage', 'namespaces', '*', 'entries', 'table', '*', 'indexes', 'unique'],
176
+ ['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
177
+ ];
178
+ const COLUMN_DEFAULT_VALUE: readonly PathSegment[] = [
179
+ 'storage',
180
+ 'namespaces',
181
+ '*',
182
+ 'entries',
183
+ 'table',
184
+ '*',
185
+ 'columns',
186
+ '*',
187
+ 'default',
188
+ 'value',
189
+ ];
190
+ const COLUMN_PATHS: readonly (readonly PathSegment[])[] = [
191
+ ['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*'],
192
+ ['storage', 'namespaces', '*', 'tables', '*', 'columns', '*'],
193
+ ];
194
+ const NATIVE_TYPE_PATHS: readonly (readonly PathSegment[])[] = [
195
+ ...COLUMN_PATHS,
196
+ ['storage', 'types', '*'],
197
+ ];
198
+ const LIST_PATHS: readonly (readonly PathSegment[])[] = [
199
+ ...COLUMN_PATHS,
200
+ ['domain', 'namespaces', '*', ['models', 'valueObjects'], '*', 'fields', '*'],
201
+ ];
202
+ const FRAMEWORK_PRESERVED_EMPTY: readonly (readonly PathSegment[])[] = [
203
+ ['domain', 'namespaces'],
204
+ ['domain', 'namespaces', '*'],
205
+ ['domain', 'namespaces', '*', 'models'],
206
+ ['domain', 'namespaces', '*', 'models', '*', 'relations'],
207
+ ['domain', 'namespaces', '*', 'models', '*', 'storage'],
208
+ ['storage', 'namespaces'],
209
+ ['storage', 'namespaces', '*', 'entries'],
210
+ ['roots'],
211
+ ['extensions'],
212
+ ['extensions', '*'],
213
+ ['capabilities'],
214
+ ['meta'],
215
+ ['execution', 'mutations', 'defaults'],
216
+ ];
217
+ const TOP_LEVEL_ORDER = [
218
+ 'schemaVersion',
219
+ 'canonicalVersion',
220
+ 'targetFamily',
221
+ 'target',
222
+ 'profileHash',
223
+ 'roots',
224
+ 'domain',
225
+ 'storage',
226
+ 'execution',
227
+ 'capabilities',
228
+ 'extensions',
229
+ 'defaultControlPolicy',
230
+ 'meta',
231
+ ];
232
+
233
+ function isDefaultValue(value: Json): boolean {
234
+ if (value === false) return true;
235
+ if (Array.isArray(value)) return value.length === 0;
236
+ return isRecord(value) && Object.keys(value).length === 0;
237
+ }
238
+
239
+ function isPreservedEmpty(path: readonly string[], key: string): boolean {
240
+ if (key === 'nullable') return true;
241
+ if (key === 'elementNullable' && path.at(-2) === 'many') return true;
242
+ if (FRAMEWORK_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern))) return true;
243
+ if (
244
+ path.length >= COLUMN_DEFAULT_VALUE.length &&
245
+ matchesPath(path.slice(0, COLUMN_DEFAULT_VALUE.length), COLUMN_DEFAULT_VALUE)
246
+ )
247
+ return true;
248
+ return SQL_PRESERVED_EMPTY.some((pattern) => matchesPath(path, pattern));
249
+ }
250
+
251
+ function omitDefaults(value: Json, path: readonly string[]): Json {
252
+ if (Array.isArray(value)) return value.map((item) => omitDefaults(item, path));
253
+ if (!isRecord(value)) return value;
254
+ const result: JsonRecord = {};
255
+ for (const [key, child] of Object.entries(value)) {
256
+ const childPath = [...path, key];
257
+ if (key === '_generated') continue;
258
+ if (key === 'generated' && child === false) continue;
259
+ if ((key === 'onDelete' || key === 'onUpdate') && child === 'noAction') continue;
260
+ if (isDefaultValue(child) && !isPreservedEmpty(childPath, key)) continue;
261
+ result[key] = omitDefaults(child, childPath);
262
+ }
263
+ return result;
264
+ }
265
+
266
+ function compareByName(a: Json, b: Json): number {
267
+ const nameA = isRecord(a) && typeof a['name'] === 'string' ? a['name'] : '';
268
+ const nameB = isRecord(b) && typeof b['name'] === 'string' ? b['name'] : '';
269
+ return compareCodeUnits(nameA, nameB);
270
+ }
271
+
272
+ function sortTableArrays(storage: Json): Json {
273
+ if (!isRecord(storage)) return storage;
274
+ const namespaces = storage['namespaces'];
275
+ if (!isRecord(namespaces)) return storage;
276
+ const sortedNamespaces: JsonRecord = {};
277
+ for (const [namespaceId, namespace] of Object.entries(namespaces)) {
278
+ const entries = isRecord(namespace) ? namespace['entries'] : undefined;
279
+ const tables = isRecord(entries) ? entries['table'] : undefined;
280
+ if (!isRecord(namespace) || !isRecord(entries) || !isRecord(tables)) {
281
+ sortedNamespaces[namespaceId] = namespace;
282
+ continue;
283
+ }
284
+ const sortedTables: JsonRecord = {};
285
+ for (const [tableName, table] of Object.entries(tables)) {
286
+ if (!isRecord(table)) {
287
+ sortedTables[tableName] = table;
288
+ continue;
289
+ }
290
+ const sortedTable: JsonRecord = { ...table };
291
+ for (const key of ['checks', 'indexes', 'uniques']) {
292
+ const list = table[key];
293
+ if (Array.isArray(list)) sortedTable[key] = [...list].sort(compareByName);
294
+ }
295
+ sortedTables[tableName] = sortedTable;
296
+ }
297
+ sortedNamespaces[namespaceId] = { ...namespace, entries: { ...entries, table: sortedTables } };
298
+ }
299
+ return { ...storage, namespaces: sortedNamespaces };
300
+ }
301
+
302
+ function orderTopLevel(value: JsonRecord): JsonRecord {
303
+ const ordered: JsonRecord = {};
304
+ const remaining = new Set(Object.keys(value));
305
+ for (const key of TOP_LEVEL_ORDER) {
306
+ const child = value[key];
307
+ if (child !== undefined) {
308
+ ordered[key] = child;
309
+ remaining.delete(key);
310
+ }
311
+ }
312
+ for (const key of [...remaining].sort(compareCodeUnits)) {
313
+ const child = value[key];
314
+ if (child !== undefined) ordered[key] = child;
315
+ }
316
+ return ordered;
317
+ }
318
+
319
+ function omitNamespaceKinds(storage: JsonRecord): JsonRecord {
320
+ const namespaces = storage['namespaces'];
321
+ if (!isRecord(namespaces)) return storage;
322
+ const stripped: JsonRecord = {};
323
+ for (const [namespaceId, namespace] of Object.entries(namespaces)) {
324
+ if (isRecord(namespace)) {
325
+ const { kind: _kind, ...rest } = namespace;
326
+ stripped[namespaceId] = rest;
327
+ } else {
328
+ stripped[namespaceId] = namespace;
329
+ }
330
+ }
331
+ return { ...storage, namespaces: stripped };
332
+ }
333
+
334
+ function computeStorageHash(contract: JsonRecord): string {
335
+ const storage = isRecord(contract['storage']) ? contract['storage'] : {};
336
+ const { storageHash: _published, ...withoutHash } = storage;
337
+ const hashed: JsonRecord = {
338
+ schemaVersion: '1',
339
+ targetFamily: typeof contract['targetFamily'] === 'string' ? contract['targetFamily'] : '',
340
+ target: typeof contract['target'] === 'string' ? contract['target'] : '',
341
+ profileHash: '',
342
+ roots: {},
343
+ domain: { namespaces: {} },
344
+ storage: omitNamespaceKinds(withoutHash),
345
+ extensions: {},
346
+ capabilities: {},
347
+ meta: {},
348
+ };
349
+ const withoutDefaults = omitDefaults(hashed, []);
350
+ if (!isRecord(withoutDefaults)) return '';
351
+ const withSortedStorage = {
352
+ ...withoutDefaults,
353
+ storage: sortTableArrays(withoutDefaults['storage'] ?? {}),
354
+ };
355
+ const sorted = sortKeys(withSortedStorage);
356
+ return isRecord(sorted) ? sha256(JSON.stringify(orderTopLevel(sorted), null, 2)) : '';
357
+ }
358
+
359
+ function computeMigrationHash(metadata: JsonRecord, ops: Json): string {
360
+ const { migrationHash: _migrationHash, ...withoutHash } = metadata;
361
+ const parts = [canonicalizeJson(withoutHash), canonicalizeJson(ops)].map(sha256);
362
+ return sha256(canonicalizeJson(parts));
363
+ }
364
+
365
+ interface ProjectFiles {
366
+ readonly json: readonly string[];
367
+ readonly migrationJson: readonly string[];
368
+ readonly refs: readonly string[];
369
+ readonly migrationTs: readonly string[];
370
+ readonly leftovers: readonly string[];
371
+ }
372
+
373
+ function isDirectory(path: string): boolean {
374
+ try {
375
+ return statSync(path).isDirectory();
376
+ } catch {
377
+ return false;
378
+ }
379
+ }
380
+
381
+ function isFile(path: string): boolean {
382
+ try {
383
+ return statSync(path).isFile();
384
+ } catch {
385
+ return false;
386
+ }
387
+ }
388
+
389
+ /** A file, or a link to one: the script reads and writes through links to files. */
390
+ function isFileEntry(dir: string, entry: Dirent): boolean {
391
+ return entry.isFile() || (entry.isSymbolicLink() && isFile(join(dir, entry.name)));
392
+ }
393
+
394
+ /** A file the script reads, by the path it was reached through: its own path rather than a link's. */
395
+ interface FoundFile {
396
+ readonly path: string;
397
+ readonly linked: boolean;
398
+ readonly inMigrationPackage: boolean;
399
+ }
400
+
401
+ function listFiles(root: string): ProjectFiles {
402
+ const leftovers: string[] = [];
403
+ const visited = new Set<string>();
404
+ const found = new Map<string, FoundFile>();
405
+ const visit = (dir: string): void => {
406
+ const realDir = realpathSync(dir);
407
+ if (visited.has(realDir)) return;
408
+ visited.add(realDir);
409
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
410
+ compareCodeUnits(a.name, b.name),
411
+ );
412
+ const inMigrationPackage = entries.some(
413
+ (entry) => entry.name === 'migration.json' && isFileEntry(dir, entry),
414
+ );
415
+ for (const entry of entries) {
416
+ const path = join(dir, entry.name);
417
+ if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
418
+ leftovers.push(path);
419
+ continue;
420
+ }
421
+ if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
422
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) visit(path);
423
+ continue;
424
+ }
425
+ if (!isFileEntry(dir, entry)) continue;
426
+ const realFile = realpathSync(path);
427
+ const linked = entry.isSymbolicLink();
428
+ const known = found.get(realFile);
429
+ if (known === undefined || (known.linked && !linked))
430
+ found.set(realFile, { path, linked, inMigrationPackage });
431
+ }
432
+ };
433
+ visit(root);
434
+ const files = [...found.values()];
435
+ const named = (test: (file: FoundFile, name: string) => boolean): string[] =>
436
+ files.filter((file) => test(file, basename(file.path))).map((file) => file.path);
437
+ return {
438
+ json: named((_file, name) => name.endsWith('.json')),
439
+ migrationJson: named((_file, name) => name === 'migration.json'),
440
+ refs: named((file, name) => name.endsWith('.json') && basename(dirname(file.path)) === 'refs'),
441
+ migrationTs: named((file, name) => name === 'migration.ts' && file.inMigrationPackage),
442
+ leftovers,
443
+ };
444
+ }
445
+
446
+ function isSnapshotContract(path: string): boolean {
447
+ const snapshotDir = dirname(path);
448
+ return (
449
+ basename(path) === 'contract.json' &&
450
+ HASH.test(basename(snapshotDir)) &&
451
+ basename(dirname(snapshotDir)) === 'snapshots'
452
+ );
453
+ }
454
+
455
+ interface Rewrite {
456
+ readonly contract: JsonRecord;
457
+ readonly changed: boolean;
458
+ readonly unknownCodecs: readonly string[];
459
+ readonly defaultRewrites: ReadonlyMap<string, Json>;
460
+ readonly integerEnums: ReadonlySet<string>;
461
+ readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
462
+ readonly columnCardinalities: ReadonlyMap<string, Json>;
463
+ }
464
+
465
+ /** The rewritten values of an enum typed by `sqlite/json@1`: its value set and its members by name. */
466
+ interface JsonEnumValues {
467
+ values: readonly Json[] | undefined;
468
+ readonly members: Map<string, Json>;
469
+ }
470
+
471
+ function rewriteDefault(target: string, codecId: string, value: JsonRecord): JsonRecord {
472
+ const defaultValue = value['default'];
473
+ if (target !== 'sqlite' || !isRecord(defaultValue) || defaultValue['kind'] !== 'literal')
474
+ return value;
475
+ const literal = defaultValue['value'];
476
+ if (literal === undefined) return value;
477
+ if (codecId === SQLITE_JSON_CODEC)
478
+ return { ...value, default: { ...defaultValue, value: canonicalizeJson(literal) } };
479
+ if (
480
+ SQLITE_INTEGER_CODECS.has(codecId) &&
481
+ typeof literal === 'number' &&
482
+ Number.isInteger(literal)
483
+ )
484
+ return { ...value, default: { ...defaultValue, value: BigInt(literal).toString() } };
485
+ return value;
486
+ }
487
+
488
+ function digitText(value: Json): Json {
489
+ return typeof value === 'number' && Number.isInteger(value) ? BigInt(value).toString() : value;
490
+ }
491
+
492
+ function mapRecord(value: Json, map: (key: string, child: Json) => Json): Json {
493
+ if (!isRecord(value)) return value;
494
+ const result: JsonRecord = {};
495
+ for (const [key, child] of Object.entries(value)) result[key] = map(key, child);
496
+ return result;
497
+ }
498
+
499
+ function mapEntries(
500
+ value: Json,
501
+ path: readonly string[],
502
+ patterns: readonly (readonly PathSegment[])[],
503
+ rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
504
+ ): Json {
505
+ if (!isRecord(value)) return value;
506
+ if (patterns.some((pattern) => matchesPath(path, pattern))) return rewrite(value, path);
507
+ const leadsToEntry = patterns.some(
508
+ (pattern) => path.length < pattern.length && matchesPath(path, pattern.slice(0, path.length)),
509
+ );
510
+ return leadsToEntry
511
+ ? mapRecord(value, (key, child) => mapEntries(child, [...path, key], patterns, rewrite))
512
+ : value;
513
+ }
514
+
515
+ function mapContractEntries(
516
+ contract: JsonRecord,
517
+ patterns: readonly (readonly PathSegment[])[],
518
+ rewrite: (entry: JsonRecord, path: readonly string[]) => JsonRecord,
519
+ ): JsonRecord {
520
+ const result: JsonRecord = {};
521
+ for (const [key, child] of Object.entries(contract))
522
+ result[key] = mapEntries(child, [key], patterns, rewrite);
523
+ return result;
524
+ }
525
+
526
+ /** Release 8.0.0-rc.14 stored a list column or field as `many: true`; the current form is `many: { elementNullable: false }`. */
527
+ function currentListForm(entry: JsonRecord): JsonRecord {
528
+ return entry['many'] === true ? { ...entry, many: { elementNullable: false } } : entry;
529
+ }
530
+
531
+ function storageColumnKey(namespaceId: string, table: string, column: string): string {
532
+ return `${namespaceId}\u0000${table}\u0000${column}`;
533
+ }
534
+
535
+ /** The `many` of every storage column, `false` for a column that is not a list. */
536
+ function columnCardinalities(contract: JsonRecord): ReadonlyMap<string, Json> {
537
+ const cardinalities = new Map<string, Json>();
538
+ const storage = contract['storage'];
539
+ const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
540
+ for (const [namespaceId, namespace] of isRecord(namespaces) ? Object.entries(namespaces) : []) {
541
+ if (!isRecord(namespace)) continue;
542
+ const entries = namespace['entries'];
543
+ const tables = isRecord(entries) ? entries['table'] : namespace['tables'];
544
+ for (const [tableName, table] of isRecord(tables) ? Object.entries(tables) : []) {
545
+ const columns = isRecord(table) ? table['columns'] : undefined;
546
+ for (const [columnName, column] of isRecord(columns) ? Object.entries(columns) : [])
547
+ cardinalities.set(
548
+ storageColumnKey(namespaceId, tableName, columnName),
549
+ isRecord(column) ? (column['many'] ?? false) : false,
550
+ );
551
+ }
552
+ }
553
+ return cardinalities;
554
+ }
555
+
556
+ function enumKey(namespaceId: string, name: string): string {
557
+ return `${namespaceId}\u0000${name}`;
558
+ }
559
+
560
+ /**
561
+ * On SQLite the integer codecs store digit text and `sqlite/json@1` stores the JSON text of a
562
+ * document, so the members of an enum typed by one of them, in the domain and in the storage value
563
+ * set its columns name, are rewritten to that text too.
564
+ */
565
+ function rewriteSqliteEnums(contract: JsonRecord): {
566
+ readonly contract: JsonRecord;
567
+ readonly integerEnums: ReadonlySet<string>;
568
+ readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
569
+ } {
570
+ const integerEnums = new Set<string>();
571
+ const jsonEnums = new Map<string, JsonEnumValues>();
572
+ const jsonEnum = (key: string): JsonEnumValues => {
573
+ const existing = jsonEnums.get(key);
574
+ if (existing !== undefined) return existing;
575
+ const created: JsonEnumValues = { values: undefined, members: new Map() };
576
+ jsonEnums.set(key, created);
577
+ return created;
578
+ };
579
+ const domainCodecs = new Map<string, string>();
580
+ const domain = mapRecord(contract['domain'] ?? null, (key, namespaces) =>
581
+ key !== 'namespaces'
582
+ ? namespaces
583
+ : mapRecord(namespaces, (namespaceId, namespace) =>
584
+ mapRecord(namespace, (kind, enums) =>
585
+ kind !== 'enum'
586
+ ? enums
587
+ : mapRecord(enums, (name, enumType) => {
588
+ const codecId = isRecord(enumType) ? enumType['codecId'] : undefined;
589
+ if (typeof codecId === 'string')
590
+ domainCodecs.set(enumKey(namespaceId, name), codecId);
591
+ const members = isRecord(enumType) ? enumType['members'] : undefined;
592
+ if (!isRecord(enumType) || typeof codecId !== 'string' || !Array.isArray(members))
593
+ return enumType;
594
+ const key = enumKey(namespaceId, name);
595
+ if (SQLITE_INTEGER_CODECS.has(codecId)) {
596
+ integerEnums.add(key);
597
+ return {
598
+ ...enumType,
599
+ members: members.map((member) =>
600
+ isRecord(member) && member['value'] !== undefined
601
+ ? { ...member, value: digitText(member['value']) }
602
+ : member,
603
+ ),
604
+ };
605
+ }
606
+ if (codecId !== SQLITE_JSON_CODEC) return enumType;
607
+ const rewritten = jsonEnum(key);
608
+ return {
609
+ ...enumType,
610
+ members: members.map((member) => {
611
+ const value = isRecord(member) ? member['value'] : undefined;
612
+ if (!isRecord(member) || value === undefined) return member;
613
+ const text = canonicalizeJson(value);
614
+ if (typeof member['name'] === 'string')
615
+ rewritten.members.set(member['name'], text);
616
+ return { ...member, value: text };
617
+ }),
618
+ };
619
+ }),
620
+ ),
621
+ ),
622
+ );
623
+
624
+ const columnCodecs = new Map<string, string>();
625
+ const storage = contract['storage'];
626
+ const namespaces = isRecord(storage) ? storage['namespaces'] : undefined;
627
+ for (const namespace of isRecord(namespaces) ? Object.values(namespaces) : []) {
628
+ const entries = isRecord(namespace) ? namespace['entries'] : undefined;
629
+ const tables = isRecord(entries) ? entries['table'] : undefined;
630
+ for (const table of isRecord(tables) ? Object.values(tables) : []) {
631
+ const columns = isRecord(table) ? table['columns'] : undefined;
632
+ for (const column of isRecord(columns) ? Object.values(columns) : []) {
633
+ const valueSet = isRecord(column) ? column['valueSet'] : undefined;
634
+ const codecId = isRecord(column) ? column['codecId'] : undefined;
635
+ if (
636
+ isRecord(valueSet) &&
637
+ typeof valueSet['namespaceId'] === 'string' &&
638
+ typeof valueSet['entityName'] === 'string' &&
639
+ typeof codecId === 'string'
640
+ )
641
+ columnCodecs.set(enumKey(valueSet['namespaceId'], valueSet['entityName']), codecId);
642
+ }
643
+ }
644
+ }
645
+
646
+ const nextStorage = mapRecord(storage ?? null, (key, storageNamespaces) =>
647
+ key !== 'namespaces'
648
+ ? storageNamespaces
649
+ : mapRecord(storageNamespaces, (namespaceId, namespace) =>
650
+ mapRecord(namespace, (namespaceKey, entries) =>
651
+ namespaceKey !== 'entries'
652
+ ? entries
653
+ : mapRecord(entries, (entryKind, valueSets) =>
654
+ entryKind !== 'valueSet'
655
+ ? valueSets
656
+ : mapRecord(valueSets, (name, valueSet) => {
657
+ const key = enumKey(namespaceId, name);
658
+ const codecId = columnCodecs.get(key) ?? domainCodecs.get(key);
659
+ const values = isRecord(valueSet) ? valueSet['values'] : undefined;
660
+ if (codecId === undefined || !isRecord(valueSet) || !Array.isArray(values))
661
+ return valueSet;
662
+ if (SQLITE_INTEGER_CODECS.has(codecId)) {
663
+ integerEnums.add(key);
664
+ return { ...valueSet, values: values.map(digitText) };
665
+ }
666
+ if (codecId !== SQLITE_JSON_CODEC) return valueSet;
667
+ const texts = values.map(canonicalizeJson);
668
+ jsonEnum(key).values = texts;
669
+ return { ...valueSet, values: texts };
670
+ }),
671
+ ),
672
+ ),
673
+ ),
674
+ );
675
+
676
+ return {
677
+ contract: {
678
+ ...contract,
679
+ ...(contract['domain'] === undefined ? {} : { domain }),
680
+ ...(storage === undefined ? {} : { storage: nextStorage }),
681
+ },
682
+ integerEnums,
683
+ jsonEnums,
684
+ };
685
+ }
686
+
687
+ function dataTypesFor(
688
+ target: string,
689
+ extra: ReadonlyMap<string, string>,
690
+ ): Readonly<Record<string, string>> {
691
+ return { ...Object.fromEntries(extra), ...DATA_TYPES[target] };
692
+ }
693
+
694
+ const CODEC_ID = /^[^\s=]+$/;
695
+ const DATA_TYPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
696
+
697
+ interface Options {
698
+ readonly root: string;
699
+ readonly dataTypes: ReadonlyMap<string, string>;
700
+ readonly errors: readonly string[];
701
+ }
702
+
703
+ function parseOptions(args: readonly string[]): Options {
704
+ const dataTypes = new Map<string, string>();
705
+ const errors: string[] = [];
706
+ const positional: string[] = [];
707
+ for (let index = 0; index < args.length; index++) {
708
+ const arg = args[index] ?? '';
709
+ if (arg !== '--data-type' && !arg.startsWith('--data-type=')) {
710
+ if (arg.startsWith('--')) errors.push(`${arg}: unknown option`);
711
+ else positional.push(arg);
712
+ continue;
713
+ }
714
+ const value = arg === '--data-type' ? (args[++index] ?? '') : arg.slice('--data-type='.length);
715
+ const separator = value.indexOf('=');
716
+ const codecId = value.slice(0, separator);
717
+ const dataType = value.slice(separator + 1);
718
+ if (separator === -1 || !CODEC_ID.test(codecId) || !DATA_TYPE_ID.test(dataType)) {
719
+ errors.push(
720
+ `--data-type ${value}: expected <codec id>=<data type id>, for example acme/shape@1=acme/shape`,
721
+ );
722
+ continue;
723
+ }
724
+ dataTypes.set(codecId, dataType);
725
+ }
726
+ return { root: resolve(positional[0] ?? process.cwd()), dataTypes, errors };
727
+ }
728
+
729
+ function rewriteContract(contract: JsonRecord, extra: ReadonlyMap<string, string>): Rewrite {
730
+ const target = typeof contract['target'] === 'string' ? contract['target'] : '';
731
+ const table = dataTypesFor(target, extra);
732
+ const unknownCodecs = new Set<string>();
733
+ const defaultRewrites = new Map<string, Json>();
734
+ let changed = false;
735
+
736
+ const rewriteEntry = (value: JsonRecord, path: readonly string[]): JsonRecord => {
737
+ const codecId = value['codecId'];
738
+ if (typeof codecId !== 'string' || typeof value['nativeType'] !== 'string') return value;
739
+ const dataType = table[codecId];
740
+ if (dataType === undefined) {
741
+ unknownCodecs.add(codecId);
742
+ return value;
743
+ }
744
+ changed = true;
745
+ const { nativeType: _nativeType, ...rest } = value;
746
+ const rewritten = rewriteDefault(target, codecId, { ...rest, dataType });
747
+ const before = value['default'];
748
+ const after = rewritten['default'];
749
+ if (isRecord(before) && isRecord(after) && before['value'] !== after['value']) {
750
+ const columnKey = path.slice(-3);
751
+ const newValue = after['value'];
752
+ if (columnKey[1] === 'columns' && newValue !== undefined)
753
+ defaultRewrites.set(`${columnKey[0]}\u0000${columnKey[2]}`, newValue);
754
+ }
755
+ return rewritten;
756
+ };
757
+
758
+ const rewritten = mapContractEntries(contract, NATIVE_TYPE_PATHS, rewriteEntry);
759
+ const extensions = contract['extensions'];
760
+ for (const pack of isRecord(extensions) ? Object.values(extensions) : []) {
761
+ const types = isRecord(pack) ? pack['types'] : undefined;
762
+ const storage = isRecord(types) ? types['storage'] : undefined;
763
+ if (Array.isArray(storage) && storage.some((entry) => isRecord(entry) && 'nativeType' in entry))
764
+ changed = true;
765
+ }
766
+ if (changed && isRecord(extensions)) {
767
+ rewritten['extensions'] = mapRecord(extensions, (_packId, pack) => {
768
+ const types = isRecord(pack) ? pack['types'] : undefined;
769
+ if (!isRecord(pack) || !isRecord(types) || !('storage' in types)) return pack;
770
+ const { storage: _storage, ...rest } = types;
771
+ return { ...pack, types: rest };
772
+ });
773
+ }
774
+ const withLists = changed
775
+ ? mapContractEntries(rewritten, LIST_PATHS, currentListForm)
776
+ : rewritten;
777
+ const enums =
778
+ target === 'sqlite' && changed
779
+ ? rewriteSqliteEnums(withLists)
780
+ : { contract: withLists, integerEnums: new Set<string>(), jsonEnums: new Map() };
781
+ return {
782
+ contract: enums.contract,
783
+ changed,
784
+ unknownCodecs: [...unknownCodecs].sort(compareCodeUnits),
785
+ defaultRewrites,
786
+ integerEnums: enums.integerEnums,
787
+ jsonEnums: enums.jsonEnums,
788
+ columnCardinalities: columnCardinalities(withLists),
789
+ };
790
+ }
791
+
792
+ function withStorageHash(contract: JsonRecord, hash: string): JsonRecord {
793
+ const storage = isRecord(contract['storage']) ? contract['storage'] : {};
794
+ return { ...contract, storage: { ...storage, storageHash: hash } };
795
+ }
796
+
797
+ function emittedForm(contract: JsonRecord, original: string): string {
798
+ const { _generated: generated, ...rest } = contract;
799
+ const sorted = sortKeys(rest);
800
+ const ordered = isRecord(sorted) ? orderTopLevel(sorted) : {};
801
+ if (generated !== undefined) ordered['_generated'] = generated;
802
+ return `${JSON.stringify(ordered, null, 2)}${original.endsWith('\n') ? '\n' : ''}`;
803
+ }
804
+
805
+ function snapshotForm(contract: JsonRecord): string {
806
+ return `${canonicalizeJson(contract)}\n`;
807
+ }
808
+
809
+ interface Block {
810
+ readonly start: number;
811
+ end: number;
812
+ readonly label: string;
813
+ readonly parent: Block | undefined;
814
+ }
815
+
816
+ function scanBlocks(text: string): Block[] {
817
+ const blocks: Block[] = [];
818
+ const open: Block[] = [];
819
+ let quote: string | undefined;
820
+ for (let index = 0; index < text.length; index++) {
821
+ const char = text[index];
822
+ if (quote !== undefined) {
823
+ if (char === '\\') index++;
824
+ else if (char === quote) quote = undefined;
825
+ continue;
826
+ }
827
+ if (char === "'" || char === '"' || char === '`') {
828
+ quote = char;
829
+ continue;
830
+ }
831
+ if (char === '{') {
832
+ const key =
833
+ /(?:readonly\s+)?([\p{ID_Start}_$][\p{ID_Continue}$]*|'[^']*'|"[^"]*")\s*\??\s*:\s*$/u.exec(
834
+ text.slice(Math.max(0, index - 200), index),
835
+ );
836
+ const block: Block = {
837
+ start: index,
838
+ end: text.length,
839
+ label: key?.[1]?.replace(/^['"]|['"]$/g, '') ?? '',
840
+ parent: open.at(-1),
841
+ };
842
+ open.push(block);
843
+ blocks.push(block);
844
+ } else if (char === '}') {
845
+ const block = open.pop();
846
+ if (block !== undefined) block.end = index;
847
+ }
848
+ }
849
+ return blocks;
850
+ }
851
+
852
+ function innermostBlock(blocks: readonly Block[], position: number): Block | undefined {
853
+ let found: Block | undefined;
854
+ for (const block of blocks) {
855
+ if (
856
+ block.start < position &&
857
+ position < block.end &&
858
+ (found === undefined || block.start > found.start)
859
+ )
860
+ found = block;
861
+ }
862
+ return found;
863
+ }
864
+
865
+ function isColumnBlock(block: Block): boolean {
866
+ return block.parent?.label === 'columns';
867
+ }
868
+
869
+ function isStorageTypeBlock(blocks: readonly Block[], block: Block): boolean {
870
+ const types = block.parent;
871
+ return (
872
+ types?.label === 'types' &&
873
+ blocks.some((other) => other.parent === types.parent && other.label === 'namespaces')
874
+ );
875
+ }
876
+
877
+ function isInColumnDefault(block: Block): boolean {
878
+ for (let ancestor = block.parent; ancestor !== undefined; ancestor = ancestor.parent)
879
+ if (
880
+ ancestor.label === 'default' &&
881
+ ancestor.parent !== undefined &&
882
+ isColumnBlock(ancestor.parent)
883
+ )
884
+ return true;
885
+ return false;
886
+ }
887
+
888
+ function holdsNativeType(blocks: readonly Block[], block: Block): boolean {
889
+ return (isColumnBlock(block) || isStorageTypeBlock(blocks, block)) && !isInColumnDefault(block);
890
+ }
891
+
892
+ /** The key of a storage column block, in the `entries.table` and the older `tables` layout. */
893
+ function columnBlockKey(block: Block): string | undefined {
894
+ const table = block.parent?.parent;
895
+ const tables = table?.parent;
896
+ const namespace = tables?.label === 'table' ? tables.parent?.parent : tables?.parent;
897
+ if (!isColumnBlock(block) || table === undefined || namespace === undefined) return undefined;
898
+ if (isInColumnDefault(block)) return undefined;
899
+ return storageColumnKey(namespace.label, table.label, block.label);
900
+ }
901
+
902
+ function manyType(many: Json): string {
903
+ return isRecord(many) && typeof many['elementNullable'] === 'boolean'
904
+ ? `{ readonly elementNullable: ${many['elementNullable']} }`
905
+ : 'false';
906
+ }
907
+
908
+ /** Where a member written last in `block` goes, and the text that writes it. */
909
+ function lastMember(text: string, block: Block, member: string): { start: number; text: string } {
910
+ const lineStart = text.lastIndexOf('\n', block.end - 1) + 1;
911
+ const closingIndent = text.slice(lineStart, block.end);
912
+ if (closingIndent.trim() === '')
913
+ return { start: lineStart, text: `${closingIndent} ${member};\n` };
914
+ const contentEnd = block.start + 1 + text.slice(block.start + 1, block.end).trimEnd().length;
915
+ return {
916
+ start: contentEnd,
917
+ text: text[contentEnd - 1] === ';' ? ` ${member};` : `; ${member}`,
918
+ };
919
+ }
920
+
921
+ function prettierString(value: string): string {
922
+ const singles = (value.match(/'/g) ?? []).length;
923
+ const doubles = (value.match(/"/g) ?? []).length;
924
+ const quote = singles > doubles ? '"' : "'";
925
+ const escaped = JSON.stringify(value)
926
+ .slice(1, -1)
927
+ .replaceAll(String.fromCharCode(0x2028), '\\u2028')
928
+ .replaceAll(String.fromCharCode(0x2029), '\\u2029')
929
+ .replace(
930
+ /\\(.)|(["'])/gs,
931
+ (match: string, escapedChar: string | undefined, bare: string | undefined) => {
932
+ if (bare !== undefined) return bare === quote ? `\\${bare}` : bare;
933
+ if (escapedChar === quote) return match;
934
+ if (escapedChar === '"' || escapedChar === "'") return escapedChar;
935
+ return match;
936
+ },
937
+ );
938
+ return `${quote}${escaped}${quote}`;
939
+ }
940
+
941
+ function typeLiteral(value: Json): string {
942
+ return typeof value === 'string' ? prettierString(value) : JSON.stringify(value);
943
+ }
944
+
945
+ function genericArgumentEnd(text: string, start: number): number {
946
+ let depth = 0;
947
+ let quote: string | undefined;
948
+ for (let index = start; index < text.length; index++) {
949
+ const char = text[index];
950
+ if (quote !== undefined) {
951
+ if (char === '\\') index++;
952
+ else if (char === quote) quote = undefined;
953
+ continue;
954
+ }
955
+ if (char === "'" || char === '"' || char === '`') quote = char;
956
+ else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
957
+ else if (char === '}' || char === ']' || char === ')') depth--;
958
+ else if (char === '>') {
959
+ if (depth === 0) return index;
960
+ depth--;
961
+ }
962
+ }
963
+ return text.length;
964
+ }
965
+
966
+ function memberOf(
967
+ text: string,
968
+ blocks: readonly Block[],
969
+ block: Block,
970
+ name: string,
971
+ ): string | undefined {
972
+ const pattern = new RegExp(`readonly ${name}: (['"])((?:\\\\.|(?!\\1).)*)\\1`, 'g');
973
+ for (const member of text.slice(block.start, block.end).matchAll(pattern)) {
974
+ if (innermostBlock(blocks, block.start + member.index) === block) return member[2];
975
+ }
976
+ return undefined;
977
+ }
978
+
979
+ /** The end of the type literal that starts at `start`: the first `;`, `,` or closing bracket outside it. */
980
+ function literalEnd(text: string, start: number): number {
981
+ let depth = 0;
982
+ let quote: string | undefined;
983
+ for (let index = start; index < text.length; index++) {
984
+ const char = text[index];
985
+ if (quote !== undefined) {
986
+ if (char === '\\') index++;
987
+ else if (char === quote) quote = undefined;
988
+ continue;
989
+ }
990
+ if (char === "'" || char === '"' || char === '`') quote = char;
991
+ else if (char === '{' || char === '[' || char === '(' || char === '<') depth++;
992
+ else if (char === '}' || char === ']' || char === ')' || char === '>') {
993
+ if (depth === 0) return index;
994
+ depth--;
995
+ } else if ((char === ';' || char === ',') && depth === 0) return index;
996
+ }
997
+ return text.length;
998
+ }
999
+
1000
+ function bracketEnd(text: string, open: number): number {
1001
+ let depth = 0;
1002
+ let quote: string | undefined;
1003
+ for (let index = open; index < text.length; index++) {
1004
+ const char = text[index];
1005
+ if (quote !== undefined) {
1006
+ if (char === '\\') index++;
1007
+ else if (char === quote) quote = undefined;
1008
+ continue;
1009
+ }
1010
+ if (char === "'" || char === '"' || char === '`') quote = char;
1011
+ else if (char === '[' || char === '{' || char === '(') depth++;
1012
+ else if (char === ']' || char === '}' || char === ')') {
1013
+ depth--;
1014
+ if (depth === 0) return index + 1;
1015
+ }
1016
+ }
1017
+ return text.length;
1018
+ }
1019
+
1020
+ function statementRemoval(
1021
+ text: string,
1022
+ start: number,
1023
+ end: number,
1024
+ ): { start: number; end: number; text: string } {
1025
+ const after = /^;?[ \t]*/.exec(text.slice(end))?.[0].length ?? 0;
1026
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1;
1027
+ const ownsLines = text.slice(lineStart, start).trim() === '' && text[end + after] === '\n';
1028
+ return ownsLines
1029
+ ? { start: lineStart, end: end + after + 1, text: '' }
1030
+ : { start, end: end + after, text: '' };
1031
+ }
1032
+
1033
+ function quoteIntegers(text: string): string {
1034
+ return text.replace(/(?<![\w'".-])-?\d+(?![\w'".])/g, (digits: string) => `'${BigInt(digits)}'`);
1035
+ }
1036
+
1037
+ function rewriteDts(
1038
+ text: string,
1039
+ target: string,
1040
+ extra: ReadonlyMap<string, string>,
1041
+ defaultRewrites: ReadonlyMap<string, Json>,
1042
+ integerEnums: ReadonlySet<string>,
1043
+ jsonEnums: ReadonlyMap<string, JsonEnumValues>,
1044
+ columnCardinalities: ReadonlyMap<string, Json>,
1045
+ hashes: ReadonlyMap<string, string>,
1046
+ ): string {
1047
+ const table = dataTypesFor(target, extra);
1048
+ const blocks = scanBlocks(text);
1049
+ const edits: { start: number; end: number; text: string }[] = [];
1050
+
1051
+ for (const match of text.matchAll(/readonly storage: readonly \[/g)) {
1052
+ const types = innermostBlock(blocks, match.index);
1053
+ if (types?.label !== 'types' || types.parent?.parent?.label !== 'extensions') continue;
1054
+ const end = bracketEnd(text, match.index + match[0].length - 1);
1055
+ edits.push(statementRemoval(text, match.index, end));
1056
+ }
1057
+
1058
+ for (const match of text.matchAll(/readonly nativeType: (['"])(?:\\.|(?!\1).)*\1/g)) {
1059
+ const block = innermostBlock(blocks, match.index);
1060
+ if (block === undefined || !holdsNativeType(blocks, block)) continue;
1061
+ const codecId = memberOf(text, blocks, block, 'codecId');
1062
+ const dataType = codecId === undefined ? undefined : table[codecId];
1063
+ if (dataType === undefined) continue;
1064
+ edits.push({
1065
+ start: match.index,
1066
+ end: match.index + match[0].length,
1067
+ text: `readonly dataType: ${prettierString(dataType)}`,
1068
+ });
1069
+ }
1070
+
1071
+ const listMembers = new Set<Block>();
1072
+ for (const match of text.matchAll(/readonly many: (true|false|\{)/g)) {
1073
+ const block = innermostBlock(blocks, match.index);
1074
+ if (block === undefined) continue;
1075
+ listMembers.add(block);
1076
+ if (match[1] !== 'true') continue;
1077
+ const key = columnBlockKey(block);
1078
+ const many = key === undefined ? undefined : columnCardinalities.get(key);
1079
+ if (many === undefined && block.parent?.label !== 'fields') continue;
1080
+ edits.push({
1081
+ start: match.index,
1082
+ end: match.index + match[0].length,
1083
+ text: `readonly many: ${manyType(many ?? { elementNullable: false })}`,
1084
+ });
1085
+ }
1086
+ for (const block of blocks) {
1087
+ const key = columnBlockKey(block);
1088
+ const many = key === undefined ? undefined : columnCardinalities.get(key);
1089
+ if (many === undefined || listMembers.has(block)) continue;
1090
+ const member = lastMember(text, block, `readonly many: ${manyType(many)}`);
1091
+ edits.push({ start: member.start, end: member.start, text: member.text });
1092
+ }
1093
+
1094
+ for (const match of text.matchAll(/DefaultLiteralValue<\s*(['"])(?:\\.|(?!\1).)*\1\s*,\s*/g)) {
1095
+ const argumentStart = match.index + match[0].length;
1096
+ const defaultBlock = innermostBlock(blocks, match.index);
1097
+ const column = defaultBlock?.parent;
1098
+ const tableBlock = column?.parent?.parent;
1099
+ if (column === undefined || tableBlock === undefined || column.parent?.label !== 'columns')
1100
+ continue;
1101
+ const value = defaultRewrites.get(`${tableBlock.label}\u0000${column.label}`);
1102
+ if (value === undefined) continue;
1103
+ const argumentEnd = genericArgumentEnd(text, argumentStart);
1104
+ const trimmedEnd = argumentStart + text.slice(argumentStart, argumentEnd).trimEnd().length;
1105
+ edits.push({ start: argumentStart, end: trimmedEnd, text: typeLiteral(value) });
1106
+ }
1107
+
1108
+ for (const match of text.matchAll(/readonly values: readonly \[([^\]]*)\]/g)) {
1109
+ const valueSet = innermostBlock(blocks, match.index);
1110
+ const kind = valueSet?.parent;
1111
+ const namespace = kind?.parent?.parent;
1112
+ if (
1113
+ valueSet === undefined ||
1114
+ kind?.label !== 'valueSet' ||
1115
+ kind.parent?.label !== 'entries' ||
1116
+ namespace === undefined ||
1117
+ !integerEnums.has(enumKey(namespace.label, valueSet.label))
1118
+ )
1119
+ continue;
1120
+ const values = match[1] ?? '';
1121
+ const start = match.index + match[0].length - values.length - 1;
1122
+ edits.push({ start, end: start + values.length, text: quoteIntegers(values) });
1123
+ }
1124
+
1125
+ for (const match of text.matchAll(/readonly value: (-?\d+)(?=\s*[;},])/g)) {
1126
+ const member = innermostBlock(blocks, match.index);
1127
+ const enumType = member?.parent;
1128
+ const namespace = enumType?.parent?.parent;
1129
+ const digits = match[1] ?? '';
1130
+ if (
1131
+ member === undefined ||
1132
+ member.label !== '' ||
1133
+ enumType?.parent?.label !== 'enum' ||
1134
+ namespace === undefined ||
1135
+ !integerEnums.has(enumKey(namespace.label, enumType.label))
1136
+ )
1137
+ continue;
1138
+ const start = match.index + match[0].length - digits.length;
1139
+ edits.push({ start, end: start + digits.length, text: quoteIntegers(digits) });
1140
+ }
1141
+
1142
+ for (const match of text.matchAll(/readonly values: readonly \[/g)) {
1143
+ const valueSet = innermostBlock(blocks, match.index);
1144
+ const kind = valueSet?.parent;
1145
+ const namespace = kind?.parent?.parent;
1146
+ if (
1147
+ valueSet === undefined ||
1148
+ kind?.label !== 'valueSet' ||
1149
+ kind.parent?.label !== 'entries' ||
1150
+ namespace === undefined
1151
+ )
1152
+ continue;
1153
+ const values = jsonEnums.get(enumKey(namespace.label, valueSet.label))?.values;
1154
+ if (values === undefined) continue;
1155
+ const open = match.index + match[0].length - 1;
1156
+ edits.push({
1157
+ start: open + 1,
1158
+ end: bracketEnd(text, open) - 1,
1159
+ text: values.map(typeLiteral).join(', '),
1160
+ });
1161
+ }
1162
+
1163
+ for (const match of text.matchAll(/readonly value: /g)) {
1164
+ const member = innermostBlock(blocks, match.index);
1165
+ const enumType = member?.parent;
1166
+ const namespace = enumType?.parent?.parent;
1167
+ if (
1168
+ member === undefined ||
1169
+ member.label !== '' ||
1170
+ enumType?.parent?.label !== 'enum' ||
1171
+ namespace === undefined
1172
+ )
1173
+ continue;
1174
+ const name = memberOf(text, blocks, member, 'name');
1175
+ const value =
1176
+ name === undefined
1177
+ ? undefined
1178
+ : jsonEnums.get(enumKey(namespace.label, enumType.label))?.members.get(name);
1179
+ if (value === undefined) continue;
1180
+ const start = match.index + match[0].length;
1181
+ const end = start + text.slice(start, literalEnd(text, start)).trimEnd().length;
1182
+ edits.push({ start, end, text: typeLiteral(value) });
1183
+ }
1184
+
1185
+ for (const match of text.matchAll(/(['"])([0-9a-f]{64})\1/g)) {
1186
+ const hash = match[2];
1187
+ const next = hash === undefined ? undefined : hashes.get(hash);
1188
+ if (next === undefined) continue;
1189
+ edits.push({
1190
+ start: match.index,
1191
+ end: match.index + match[0].length,
1192
+ text: `${match[1]}${next}${match[1]}`,
1193
+ });
1194
+ }
1195
+
1196
+ let result = text;
1197
+ for (const edit of edits.sort((a, b) => b.start - a.start))
1198
+ result = `${result.slice(0, edit.start)}${edit.text}${result.slice(edit.end)}`;
1199
+ return result;
1200
+ }
1201
+
1202
+ function replaceQuotedHashes(text: string, hashes: ReadonlyMap<string, string>): string {
1203
+ return text.replace(/"([0-9a-f]{64})"/g, (match: string, hash: string) => {
1204
+ const next = hashes.get(hash);
1205
+ return next === undefined ? match : `"${next}"`;
1206
+ });
1207
+ }
1208
+
1209
+ interface ContractPlan {
1210
+ readonly path: string;
1211
+ readonly snapshot: boolean;
1212
+ readonly oldHash: string;
1213
+ readonly newHash: string;
1214
+ readonly content: string;
1215
+ readonly target: string;
1216
+ readonly defaultRewrites: ReadonlyMap<string, Json>;
1217
+ readonly integerEnums: ReadonlySet<string>;
1218
+ readonly jsonEnums: ReadonlyMap<string, JsonEnumValues>;
1219
+ readonly columnCardinalities: ReadonlyMap<string, Json>;
1220
+ }
1221
+
1222
+ function readDirectory(dir: string): Map<string, string> {
1223
+ const files = new Map<string, string>();
1224
+ if (!existsSync(dir)) return files;
1225
+ for (const entry of readdirSync(dir, { withFileTypes: true }))
1226
+ if (isFileEntry(dir, entry) && !entry.name.endsWith(TEMPORARY_SUFFIX))
1227
+ files.set(entry.name, readFileSync(join(dir, entry.name), 'utf8'));
1228
+ return files;
1229
+ }
1230
+
1231
+ function sameFiles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
1232
+ return a.size === b.size && [...a].every(([name, content]) => b.get(name) === content);
1233
+ }
1234
+
1235
+ function temporaryPath(path: string): string {
1236
+ return join(dirname(path), `.${basename(path)}${TEMPORARY_SUFFIX}`);
1237
+ }
1238
+
1239
+ function writeWithModeOf(path: string, text: string, modeSource: string): void {
1240
+ writeFileSync(path, text);
1241
+ if (existsSync(modeSource)) chmodSync(path, statSync(modeSource).mode & 0o7777);
1242
+ }
1243
+
1244
+ function writeFile(path: string, text: string): void {
1245
+ const target = realpathSync(path);
1246
+ const temporary = temporaryPath(target);
1247
+ writeWithModeOf(temporary, text, target);
1248
+ renameSync(temporary, target);
1249
+ }
1250
+
1251
+ function writeDirectory(dir: string, files: ReadonlyMap<string, string>, sourceDir: string): void {
1252
+ const temporary = temporaryPath(dir);
1253
+ mkdirSync(temporary);
1254
+ for (const [name, text] of files)
1255
+ writeWithModeOf(join(temporary, name), text, join(sourceDir, name));
1256
+ renameSync(temporary, dir);
1257
+ }
1258
+
1259
+ function removeDirectory(dir: string): void {
1260
+ const temporary = temporaryPath(dir);
1261
+ renameSync(dir, temporary);
1262
+ rmSync(temporary, { recursive: true, force: true });
1263
+ }
1264
+
1265
+ function count(amount: number, singular: string, plural: string): string {
1266
+ return `${amount} ${amount === 1 ? singular : plural}`;
1267
+ }
1268
+
1269
+ function summary(
1270
+ root: string,
1271
+ contractsFound: number,
1272
+ files: number,
1273
+ directories: number,
1274
+ hashes: ReadonlyMap<string, string>,
1275
+ ): readonly string[] {
1276
+ if (contractsFound === 0) return [`No SQL contract was found under ${root}; nothing changed.`];
1277
+ if (files === 0 && directories === 0)
1278
+ return ['The project is already in the new format; nothing changed.'];
1279
+ const renamed = count(directories, 'snapshot directory', 'snapshot directories');
1280
+ const changes = `Rewrote ${count(files, 'file', 'files')} and renamed ${renamed}.`;
1281
+ if (hashes.size === 0) return [changes];
1282
+ return [
1283
+ `${changes} Storage hashes changed (old -> new):`,
1284
+ ...[...hashes]
1285
+ .sort(([a], [b]) => compareCodeUnits(a, b))
1286
+ .map(([oldHash, newHash]) => ` ${oldHash} -> ${newHash}`),
1287
+ ];
1288
+ }
1289
+
1290
+ const JSON_FILES_THAT_MUST_PARSE = new Set(['contract.json', 'migration.json']);
1291
+
1292
+ function main({ root, dataTypes, errors }: Options): number {
1293
+ if (errors.length > 0) {
1294
+ process.stderr.write(`${errors.join('\n')}\n`);
1295
+ return 1;
1296
+ }
1297
+ const display = (path: string): string => relative(root, path).split(sep).join('/');
1298
+ const files = listFiles(root);
1299
+ const plans: ContractPlan[] = [];
1300
+ const notices: string[] = [];
1301
+ const stops: string[] = [];
1302
+ let contractsFound = 0;
1303
+
1304
+ for (const path of files.json) {
1305
+ const text = readFileSync(path, 'utf8');
1306
+ const contract = parseJson(text);
1307
+ if (
1308
+ contract === undefined &&
1309
+ (JSON_FILES_THAT_MUST_PARSE.has(basename(path)) || files.refs.includes(path))
1310
+ ) {
1311
+ stops.push(`${display(path)}: not valid JSON`);
1312
+ continue;
1313
+ }
1314
+ if (
1315
+ !isRecord(contract) ||
1316
+ contract['targetFamily'] !== 'sql' ||
1317
+ typeof contract['target'] !== 'string'
1318
+ )
1319
+ continue;
1320
+ const storage = contract['storage'];
1321
+ if (!isRecord(storage)) continue;
1322
+ contractsFound++;
1323
+ const snapshot = isSnapshotContract(path);
1324
+ const stored = storage['storageHash'];
1325
+ const oldHash = snapshot ? basename(dirname(path)) : typeof stored === 'string' ? stored : '';
1326
+ const recomputes = computeStorageHash(contract) === oldHash;
1327
+ const rewrite = rewriteContract(contract, dataTypes);
1328
+ for (const codecId of rewrite.unknownCodecs)
1329
+ stops.push(
1330
+ `${display(path)}: unknown codec ${codecId}; name its data type with --data-type ${codecId}=<data type id>`,
1331
+ );
1332
+ if (!rewrite.changed) continue;
1333
+ if (!recomputes)
1334
+ notices.push(`${display(path)}: stored hash did not recompute; rehashed from content`);
1335
+ const newHash = computeStorageHash(rewrite.contract);
1336
+ const contractWithHash = withStorageHash(rewrite.contract, newHash);
1337
+ plans.push({
1338
+ path,
1339
+ snapshot,
1340
+ oldHash,
1341
+ newHash,
1342
+ content: snapshot ? snapshotForm(contractWithHash) : emittedForm(contractWithHash, text),
1343
+ target: contract['target'],
1344
+ defaultRewrites: rewrite.defaultRewrites,
1345
+ integerEnums: rewrite.integerEnums,
1346
+ jsonEnums: rewrite.jsonEnums,
1347
+ columnCardinalities: rewrite.columnCardinalities,
1348
+ });
1349
+ }
1350
+
1351
+ for (const target of [...new Set(plans.map((plan) => plan.target))].sort(compareCodeUnits)) {
1352
+ for (const [codecId, dataType] of dataTypes) {
1353
+ const known = DATA_TYPES[target]?.[codecId];
1354
+ if (known !== undefined)
1355
+ stops.push(
1356
+ `--data-type ${codecId}=${dataType}: the script already maps ${codecId} to ${known} on target ${target}`,
1357
+ );
1358
+ }
1359
+ }
1360
+
1361
+ const hashes = new Map<string, string>();
1362
+ for (const plan of [...plans].sort((a, b) => Number(b.snapshot) - Number(a.snapshot)))
1363
+ if (plan.oldHash !== plan.newHash && !hashes.has(plan.oldHash))
1364
+ hashes.set(plan.oldHash, plan.newHash);
1365
+
1366
+ const newDirectories = new Map<string, Map<string, string>>();
1367
+ const directorySources = new Map<string, string>();
1368
+ const sourceDirectories = new Set<string>();
1369
+ const removals = new Set<string>();
1370
+ const snapshotRewrites: [string, string][] = [];
1371
+ const referenceWrites = new Map<string, string>();
1372
+ const contractWrites = new Map<string, string>();
1373
+ for (const plan of plans) {
1374
+ const dtsPath = plan.snapshot
1375
+ ? join(dirname(plan.path), 'contract.d.ts')
1376
+ : `${plan.path.slice(0, -'.json'.length)}.d.ts`;
1377
+ const dtsHashes = plan.snapshot ? hashes : new Map([...hashes, [plan.oldHash, plan.newHash]]);
1378
+ const dts = existsSync(dtsPath)
1379
+ ? rewriteDts(
1380
+ readFileSync(dtsPath, 'utf8'),
1381
+ plan.target,
1382
+ dataTypes,
1383
+ plan.defaultRewrites,
1384
+ plan.integerEnums,
1385
+ plan.jsonEnums,
1386
+ plan.columnCardinalities,
1387
+ dtsHashes,
1388
+ )
1389
+ : undefined;
1390
+ if (!plan.snapshot) {
1391
+ if (dts !== undefined) referenceWrites.set(dtsPath, dts);
1392
+ contractWrites.set(plan.path, plan.content);
1393
+ continue;
1394
+ }
1395
+ const oldDir = dirname(plan.path);
1396
+ const newDir = join(dirname(oldDir), plan.newHash);
1397
+ sourceDirectories.add(oldDir);
1398
+ if (newDir === oldDir) {
1399
+ if (dts !== undefined) snapshotRewrites.push([dtsPath, dts]);
1400
+ snapshotRewrites.push([plan.path, plan.content]);
1401
+ continue;
1402
+ }
1403
+ removals.add(oldDir);
1404
+ const content = readDirectory(oldDir);
1405
+ content.set('contract.json', plan.content);
1406
+ if (dts !== undefined) content.set('contract.d.ts', dts);
1407
+ const planned = newDirectories.get(newDir);
1408
+ if (planned !== undefined && !sameFiles(planned, content)) {
1409
+ stops.push(`${display(newDir)}: snapshot directory already exists with different content`);
1410
+ continue;
1411
+ }
1412
+ newDirectories.set(newDir, content);
1413
+ directorySources.set(newDir, oldDir);
1414
+ }
1415
+ for (const [dir, content] of newDirectories) {
1416
+ if (sourceDirectories.has(dir) || !existsSync(dir)) continue;
1417
+ if (sameFiles(readDirectory(dir), content)) newDirectories.delete(dir);
1418
+ else stops.push(`${display(dir)}: snapshot directory already exists with different content`);
1419
+ }
1420
+
1421
+ for (const path of files.migrationJson) {
1422
+ const text = readFileSync(path, 'utf8');
1423
+ const metadata = parseJson(text);
1424
+ if (!isRecord(metadata)) continue;
1425
+ const from = metadata['from'];
1426
+ const to = metadata['to'];
1427
+ const nextFrom = typeof from === 'string' ? (hashes.get(from) ?? from) : from;
1428
+ const nextTo = typeof to === 'string' ? (hashes.get(to) ?? to) : to;
1429
+ if (nextFrom === from && nextTo === to) continue;
1430
+ const opsPath = join(dirname(path), 'ops.json');
1431
+ const ops = existsSync(opsPath) ? parseJson(readFileSync(opsPath, 'utf8')) : undefined;
1432
+ if (existsSync(opsPath) && ops === undefined) {
1433
+ stops.push(`${display(opsPath)}: not valid JSON`);
1434
+ continue;
1435
+ }
1436
+ const oldMigrationHash = metadata['migrationHash'];
1437
+ let next = replaceQuotedHashes(text, hashes);
1438
+ if (
1439
+ ops !== undefined &&
1440
+ typeof oldMigrationHash === 'string' &&
1441
+ nextFrom !== undefined &&
1442
+ nextTo !== undefined
1443
+ ) {
1444
+ const migrationHash = computeMigrationHash({ ...metadata, from: nextFrom, to: nextTo }, ops);
1445
+ next = next.replace(`"${oldMigrationHash}"`, `"${migrationHash}"`);
1446
+ }
1447
+ referenceWrites.set(path, next);
1448
+ }
1449
+ for (const path of files.refs) {
1450
+ const text = readFileSync(path, 'utf8');
1451
+ const next = replaceQuotedHashes(text, hashes);
1452
+ if (next !== text) referenceWrites.set(path, next);
1453
+ }
1454
+ for (const path of files.migrationTs) {
1455
+ const text = readFileSync(path, 'utf8');
1456
+ const next = text.replace(
1457
+ /(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])/g,
1458
+ (hash: string) => hashes.get(hash) ?? hash,
1459
+ );
1460
+ if (next !== text) referenceWrites.set(path, next);
1461
+ }
1462
+
1463
+ if (stops.length > 0) {
1464
+ process.stderr.write(`${[...new Set(stops)].join('\n')}\n`);
1465
+ return 1;
1466
+ }
1467
+
1468
+ try {
1469
+ for (const path of files.leftovers) rmSync(path, { recursive: true, force: true });
1470
+ for (const [dir, content] of newDirectories)
1471
+ writeDirectory(dir, content, directorySources.get(dir) ?? dir);
1472
+ for (const [path, text] of snapshotRewrites) writeFile(path, text);
1473
+ for (const [path, text] of referenceWrites) writeFile(path, text);
1474
+ for (const [path, text] of contractWrites) writeFile(path, text);
1475
+ for (const dir of removals) removeDirectory(dir);
1476
+ } catch (error) {
1477
+ const message = error instanceof Error ? error.message : String(error);
1478
+ process.stderr.write(
1479
+ `${message}; the upgrade stopped partway, run the script again to finish it\n`,
1480
+ );
1481
+ return 1;
1482
+ }
1483
+ const rewrittenFiles = snapshotRewrites.length + referenceWrites.size + contractWrites.size;
1484
+ const lines = [
1485
+ ...notices,
1486
+ ...summary(root, contractsFound, rewrittenFiles, removals.size, hashes),
1487
+ ];
1488
+ process.stdout.write(`${lines.join('\n')}\n`);
1489
+ return 0;
1490
+ }
1491
+
1492
+ process.exitCode = main(parseOptions(process.argv.slice(2)));