@trypattern/sdk 0.1.24 → 0.1.25
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/cli/app.js +53 -22
- package/dist/cli/startingValues.d.ts +5 -1
- package/dist/cli/startingValues.js +32 -42
- package/dist/server/tables.js +4 -3
- package/dist/table.d.ts +10 -1
- package/dist/table.js +10 -0
- package/package.json +1 -1
package/dist/cli/app.js
CHANGED
|
@@ -217,9 +217,9 @@ changing seed.ts or starter/. Works while the dev server runs; reload the page t
|
|
|
217
217
|
Compares the tables in schema.ts (plus Pattern's users, sessions and settings tables) with server/migrations and writes the
|
|
218
218
|
SQL that moves the database from one to the other, named <number>_<name>.sql. app start applies new
|
|
219
219
|
migrations to the local database. Prints "No schema changes" when the migrations already match.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
Rows that already exist (the live site has rows) need a value for a new required column, or for a
|
|
221
|
+
column that becomes required: give it one in schema.ts with defaults: { colour: 'white' } on the
|
|
222
|
+
table, or make it .nullable(). Without one this writes nothing and says which columns need it.`,
|
|
223
223
|
run: migration,
|
|
224
224
|
takesArgument: true
|
|
225
225
|
},
|
|
@@ -1299,38 +1299,66 @@ async function migration(name) {
|
|
|
1299
1299
|
'--name',
|
|
1300
1300
|
name
|
|
1301
1301
|
]);
|
|
1302
|
-
const
|
|
1302
|
+
const unstarted = code === 0 ? startNewColumns(name) : [];
|
|
1303
|
+
if (unstarted.length > 0) {
|
|
1304
|
+
const named = unstarted.map(({ table, column }) => `${camelCase(table)}.${camelCase(column)}`);
|
|
1305
|
+
const example = camelCase(unstarted[0]?.column ?? '');
|
|
1306
|
+
return {
|
|
1307
|
+
code: 1,
|
|
1308
|
+
data: { error: 'no starting value', columns: named },
|
|
1309
|
+
text: `✗ app migration ${name}: ${named.join(', ')} ${named.length === 1 ? 'is' : 'are'} required, and rows that already exist (the live site has rows) need a value. Nothing was written.\n next say what existing rows start with in schema.ts, on that table: defaults: { ${example}: … }, or make the column .nullable(). Then run app migration ${name} again.`
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1303
1312
|
const warning = code === 0 ? ownerBackfillWarning(name) : undefined;
|
|
1304
1313
|
return {
|
|
1305
1314
|
code,
|
|
1306
|
-
data: {
|
|
1307
|
-
|
|
1308
|
-
startingValues: notes,
|
|
1309
|
-
...(warning && { warning })
|
|
1310
|
-
},
|
|
1311
|
-
text: `${code === 0 ? '✓' : '✗'} app migration ${name}\n\n${indent(output.trimEnd())}${notes.map((note) => `\n note ${note}`).join('')}${warning === undefined ? '' : `\n\n! ${warning}`}`
|
|
1315
|
+
data: { output: output.trimEnd(), ...(warning && { warning }) },
|
|
1316
|
+
text: `${code === 0 ? '✓' : '✗'} app migration ${name}\n\n${indent(output.trimEnd())}${warning === undefined ? '' : `\n\n! ${warning}`}`
|
|
1312
1317
|
};
|
|
1313
1318
|
}
|
|
1314
|
-
function
|
|
1315
|
-
return
|
|
1316
|
-
.filter((entry) => entry.endsWith(`_${name}.sql`))
|
|
1317
|
-
.sort()
|
|
1318
|
-
.at(-1);
|
|
1319
|
+
function camelCase(name) {
|
|
1320
|
+
return name.replaceAll(/_([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
|
|
1319
1321
|
}
|
|
1320
|
-
function
|
|
1322
|
+
function journalEntries() {
|
|
1321
1323
|
const journal = join(MIGRATIONS_DIR, 'meta', '_journal.json');
|
|
1322
1324
|
if (!existsSync(journal))
|
|
1323
|
-
return
|
|
1325
|
+
return undefined;
|
|
1326
|
+
const raw = JSON.parse(readText(journal));
|
|
1324
1327
|
const entries = z
|
|
1325
1328
|
.object({
|
|
1326
1329
|
entries: z.array(z.object({ idx: z.number(), tag: z.string() }))
|
|
1327
1330
|
})
|
|
1328
|
-
.parse(
|
|
1331
|
+
.parse(raw).entries;
|
|
1332
|
+
return { journal, raw, entries };
|
|
1333
|
+
}
|
|
1334
|
+
function snapshotPath(idx) {
|
|
1335
|
+
return join(MIGRATIONS_DIR, 'meta', `${String(idx).padStart(4, '0')}_snapshot.json`);
|
|
1336
|
+
}
|
|
1337
|
+
function forgetMigration(file) {
|
|
1338
|
+
rmSync(join(MIGRATIONS_DIR, file), { force: true });
|
|
1339
|
+
const found = journalEntries();
|
|
1340
|
+
const entry = found?.entries.find((item) => `${item.tag}.sql` === file);
|
|
1341
|
+
if (found === undefined || entry === undefined)
|
|
1342
|
+
return;
|
|
1343
|
+
rmSync(snapshotPath(entry.idx), { force: true });
|
|
1344
|
+
writeFileSync(found.journal, `${JSON.stringify({
|
|
1345
|
+
...Object(found.raw),
|
|
1346
|
+
entries: Reflect.get(Object(found.raw), 'entries').filter((item) => Reflect.get(Object(item), 'tag') !== entry.tag)
|
|
1347
|
+
}, null, 2)}\n`);
|
|
1348
|
+
}
|
|
1349
|
+
function newestMigration(name) {
|
|
1350
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
1351
|
+
.filter((entry) => entry.endsWith(`_${name}.sql`))
|
|
1352
|
+
.sort()
|
|
1353
|
+
.at(-1);
|
|
1354
|
+
}
|
|
1355
|
+
function previousTables(file) {
|
|
1356
|
+
const entries = journalEntries()?.entries ?? [];
|
|
1329
1357
|
const at = entries.findIndex((entry) => `${entry.tag}.sql` === file);
|
|
1330
1358
|
const before = at > 0 ? entries[at - 1] : undefined;
|
|
1331
1359
|
if (before === undefined)
|
|
1332
1360
|
return {};
|
|
1333
|
-
const snapshot =
|
|
1361
|
+
const snapshot = snapshotPath(before.idx);
|
|
1334
1362
|
if (!existsSync(snapshot))
|
|
1335
1363
|
return {};
|
|
1336
1364
|
return z
|
|
@@ -1350,9 +1378,12 @@ function startNewColumns(name) {
|
|
|
1350
1378
|
table,
|
|
1351
1379
|
columns
|
|
1352
1380
|
]));
|
|
1353
|
-
const { sql,
|
|
1354
|
-
|
|
1355
|
-
|
|
1381
|
+
const { sql, unstarted } = withStartingValues(readText(path), tables);
|
|
1382
|
+
if (unstarted.length > 0)
|
|
1383
|
+
forgetMigration(file);
|
|
1384
|
+
else
|
|
1385
|
+
writeFileSync(path, sql);
|
|
1386
|
+
return unstarted;
|
|
1356
1387
|
}
|
|
1357
1388
|
function ownerBackfillWarning(name) {
|
|
1358
1389
|
const file = newestMigration(name);
|
|
@@ -2,8 +2,12 @@ type Columns = Readonly<Record<string, {
|
|
|
2
2
|
notNull: boolean;
|
|
3
3
|
}>>;
|
|
4
4
|
export type PreviousTables = Readonly<Record<string, Columns>>;
|
|
5
|
+
export type Unstarted = {
|
|
6
|
+
table: string;
|
|
7
|
+
column: string;
|
|
8
|
+
};
|
|
5
9
|
export declare function withStartingValues(sql: string, previous: PreviousTables): {
|
|
6
10
|
sql: string;
|
|
7
|
-
|
|
11
|
+
unstarted: Unstarted[];
|
|
8
12
|
};
|
|
9
13
|
export {};
|
|
@@ -1,58 +1,48 @@
|
|
|
1
|
-
const ADDED = /^ALTER TABLE `(\w+)` ADD `(\w+)` (text|integer|real) NOT NULL;/gm;
|
|
1
|
+
const ADDED = /^ALTER TABLE `(\w+)` ADD `(\w+)` (?:text|integer|real) NOT NULL;/gm;
|
|
2
2
|
const REBUILT = /CREATE TABLE `__new_(\w+)` \(([^;]*?)\n\);(\n(?:--> statement-breakpoint\n)?\n?)INSERT INTO `__new_\1`\(([^)]*)\) SELECT ([^;]*) FROM `\1`;/g;
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
text: "''",
|
|
6
|
-
integer: '0',
|
|
7
|
-
real: '0'
|
|
8
|
-
};
|
|
9
|
-
const SPOKEN = {
|
|
10
|
-
text: 'empty',
|
|
11
|
-
integer: '0 (or "no")',
|
|
12
|
-
real: '0'
|
|
13
|
-
};
|
|
3
|
+
const COLUMN = /^\s*`(\w+)` (?:text|integer|real)(.*)$/;
|
|
4
|
+
const STARTS_AS = / DEFAULT ('(?:[^']|'')*'|[-\w.]+)/;
|
|
14
5
|
function quoted(names) {
|
|
15
6
|
return names.split(',').map((name) => name.trim().replaceAll('"', ''));
|
|
16
7
|
}
|
|
8
|
+
function startsOf(body) {
|
|
9
|
+
const starts = new Map();
|
|
10
|
+
for (const line of body.split('\n')) {
|
|
11
|
+
const match = COLUMN.exec(line);
|
|
12
|
+
const rest = match?.[2] ?? '';
|
|
13
|
+
if (match?.[1] === undefined || !rest.includes('NOT NULL'))
|
|
14
|
+
continue;
|
|
15
|
+
if (rest.includes('PRIMARY KEY'))
|
|
16
|
+
continue;
|
|
17
|
+
starts.set(match[1], STARTS_AS.exec(rest)?.[1] ?? null);
|
|
18
|
+
}
|
|
19
|
+
return starts;
|
|
20
|
+
}
|
|
17
21
|
export function withStartingValues(sql, previous) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const unstarted = [
|
|
23
|
+
...Array.from(sql.matchAll(ADDED), ([, table = '', column = '']) => ({
|
|
24
|
+
table,
|
|
25
|
+
column
|
|
26
|
+
}))
|
|
27
|
+
];
|
|
28
|
+
const rebuilt = sql.replaceAll(REBUILT, (whole, table, body, between, into, from) => {
|
|
24
29
|
const old = previous[table];
|
|
25
30
|
if (old === undefined)
|
|
26
31
|
return whole;
|
|
27
|
-
const
|
|
28
|
-
const lines = body.split('\n').map((line) => {
|
|
29
|
-
const match = REQUIRED.exec(line);
|
|
30
|
-
if (match === null || line.includes('PRIMARY KEY'))
|
|
31
|
-
return line;
|
|
32
|
-
const [, start = '', column = '', type = '', comma = ''] = match;
|
|
33
|
-
types.set(column, type);
|
|
34
|
-
return `${start} NOT NULL DEFAULT ${STARTING_VALUES[type] ?? "''"}${comma}`;
|
|
35
|
-
});
|
|
32
|
+
const starts = startsOf(body);
|
|
36
33
|
const selected = quoted(from).map((column) => {
|
|
37
|
-
const type = types.get(column);
|
|
38
34
|
const before = old[column];
|
|
39
|
-
if (
|
|
35
|
+
if (!starts.has(column) || before?.notNull === true) {
|
|
40
36
|
return `"${column}"`;
|
|
41
|
-
const value = STARTING_VALUES[type] ?? "''";
|
|
42
|
-
if (before === undefined) {
|
|
43
|
-
started.push({ table, column, type });
|
|
44
|
-
return value;
|
|
45
37
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
const start = starts.get(column) ?? null;
|
|
39
|
+
if (start === null) {
|
|
40
|
+
unstarted.push({ table, column });
|
|
41
|
+
return `"${column}"`;
|
|
49
42
|
}
|
|
50
|
-
return `"${column}"`;
|
|
43
|
+
return before === undefined ? start : `coalesce("${column}", ${start})`;
|
|
51
44
|
});
|
|
52
|
-
return `CREATE TABLE \`__new_${table}\` (${
|
|
45
|
+
return `CREATE TABLE \`__new_${table}\` (${body}\n);${between}INSERT INTO \`__new_${table}\`(${into}) SELECT ${selected.join(', ')} FROM \`${table}\`;`;
|
|
53
46
|
});
|
|
54
|
-
return {
|
|
55
|
-
sql: rebuilt,
|
|
56
|
-
notes: started.map(({ table, column, type }) => `${table}.${column} starts as ${SPOKEN[type] ?? 'empty'} on rows that already exist. To start them on a real value, add UPDATE \`${table}\` SET \`${column}\` = … after it in the migration.`)
|
|
57
|
-
};
|
|
47
|
+
return { sql: rebuilt, unstarted };
|
|
58
48
|
}
|
package/dist/server/tables.js
CHANGED
|
@@ -12,7 +12,7 @@ function typesOf(schema) {
|
|
|
12
12
|
return [type];
|
|
13
13
|
return Array.isArray(type) ? type : [];
|
|
14
14
|
}
|
|
15
|
-
function sqlColumn(key, schema) {
|
|
15
|
+
function sqlColumn(key, schema, start) {
|
|
16
16
|
const types = typesOf(schema);
|
|
17
17
|
const name = snakeCase(key);
|
|
18
18
|
const column = types.includes('boolean')
|
|
@@ -22,7 +22,8 @@ function sqlColumn(key, schema) {
|
|
|
22
22
|
: types.includes('number')
|
|
23
23
|
? real(name)
|
|
24
24
|
: text(name);
|
|
25
|
-
|
|
25
|
+
const started = start === undefined ? column : column.default(start);
|
|
26
|
+
return types.includes('null') ? started : started.notNull();
|
|
26
27
|
}
|
|
27
28
|
function uniqueIndexName(table, columns) {
|
|
28
29
|
return [table.name, ...columns, 'unique'].map(snakeCase).join('_');
|
|
@@ -30,7 +31,7 @@ function uniqueIndexName(table, columns) {
|
|
|
30
31
|
export function sqlTable(table) {
|
|
31
32
|
const columns = Object.fromEntries(Object.entries(table.columns).map(([key, schema]) => [
|
|
32
33
|
key,
|
|
33
|
-
sqlColumn(key, schema)
|
|
34
|
+
sqlColumn(key, schema, table.defaults[key])
|
|
34
35
|
]));
|
|
35
36
|
return sqliteTable(snakeCase(table.name), {
|
|
36
37
|
id: text('id').primaryKey(),
|
package/dist/table.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type TableDefinition = {
|
|
|
17
17
|
readonly owned?: boolean;
|
|
18
18
|
readonly public?: readonly string[];
|
|
19
19
|
readonly unique?: readonly (readonly string[])[];
|
|
20
|
+
readonly defaults?: Readonly<Record<string, Value>>;
|
|
20
21
|
};
|
|
21
22
|
type OwnerColumn<Owned> = [Owned] extends [true] ? {
|
|
22
23
|
ownerId: z.ZodString;
|
|
@@ -29,6 +30,7 @@ export type Table = {
|
|
|
29
30
|
readonly owned: boolean;
|
|
30
31
|
readonly public: readonly string[];
|
|
31
32
|
readonly unique: readonly (readonly string[])[];
|
|
33
|
+
readonly defaults: Readonly<Record<string, Value>>;
|
|
32
34
|
readonly fields: z.ZodObject<Columns>;
|
|
33
35
|
readonly incoming: z.ZodObject<{
|
|
34
36
|
id: z.ZodString;
|
|
@@ -49,6 +51,7 @@ export type TableOf<Name extends string, Definition extends TableDefinition> = {
|
|
|
49
51
|
readonly owned: Definition['owned'] extends true ? true : false;
|
|
50
52
|
readonly public: readonly PublicColumn<Definition>[];
|
|
51
53
|
readonly unique: readonly (readonly string[])[];
|
|
54
|
+
readonly defaults: Readonly<Record<string, Value>>;
|
|
52
55
|
readonly fields: z.ZodObject<Definition['columns']>;
|
|
53
56
|
readonly incoming: z.ZodObject<{
|
|
54
57
|
id: z.ZodString;
|
|
@@ -96,6 +99,7 @@ type DraftTable = {
|
|
|
96
99
|
readonly owned?: boolean;
|
|
97
100
|
readonly public?: readonly string[];
|
|
98
101
|
readonly unique?: readonly (readonly string[])[];
|
|
102
|
+
readonly defaults?: Readonly<Record<string, Value>>;
|
|
99
103
|
};
|
|
100
104
|
type StaticTypeError<Message extends string> = Message;
|
|
101
105
|
type IsOwned<Definition> = Definition extends {
|
|
@@ -112,6 +116,11 @@ type ReadOf<Definition> = Definition extends {
|
|
|
112
116
|
type ColumnOf<Definition> = Definition extends {
|
|
113
117
|
readonly columns: infer C;
|
|
114
118
|
} ? keyof C & string : never;
|
|
119
|
+
type DefaultsOf<Definition> = Definition extends {
|
|
120
|
+
readonly columns: infer C extends Columns;
|
|
121
|
+
} ? {
|
|
122
|
+
readonly [Key in keyof C]?: z.output<C[Key]>;
|
|
123
|
+
} : never;
|
|
115
124
|
type OwnerNeedsOwned = StaticTypeError<"'owner' needs owned: true on this table, so every row records who made it">;
|
|
116
125
|
type CreateIsNeverOwner = StaticTypeError<"create can't be 'owner': nobody owns a row before it exists. Use 'signedIn' and the person who creates it becomes its owner">;
|
|
117
126
|
type OwnedNeedsSignedInCreate = StaticTypeError<"An owned table can't let anyone create rows: a signed-out visitor has no account to own the row. Use 'signedIn'">;
|
|
@@ -132,7 +141,7 @@ type CheckedColumns<C> = {
|
|
|
132
141
|
[Key in keyof C]: Key extends 'ownerId' ? OwnerIdIsPattern : C[Key];
|
|
133
142
|
};
|
|
134
143
|
type CheckedTable<Definition> = {
|
|
135
|
-
[Key in keyof Definition]: Key extends 'columns' ? CheckedColumns<SameOnTheWire<Definition[Key] & z.ZodRawShape>> : Key extends 'access' ? CheckedAccess<Definition, Definition[Key]> : Key extends 'writes' ? HasUnique<Definition> extends true ? Definition[Key] extends 'confirmed' ? Definition[Key] : UniqueNeedsConfirmed : Definition[Key] : Key extends 'public' ? ReadOf<Definition> extends 'anyone' ? PublicNeedsPrivateRead : Definition[Key] & readonly (ColumnOf<Definition> | 'createdAt')[] : Key extends 'unique' ? Definition[Key] & readonly (readonly ColumnOf<Definition>[])[] : Definition[Key];
|
|
144
|
+
[Key in keyof Definition]: Key extends 'columns' ? CheckedColumns<SameOnTheWire<Definition[Key] & z.ZodRawShape>> : Key extends 'access' ? CheckedAccess<Definition, Definition[Key]> : Key extends 'writes' ? HasUnique<Definition> extends true ? Definition[Key] extends 'confirmed' ? Definition[Key] : UniqueNeedsConfirmed : Definition[Key] : Key extends 'public' ? ReadOf<Definition> extends 'anyone' ? PublicNeedsPrivateRead : Definition[Key] & readonly (ColumnOf<Definition> | 'createdAt')[] : Key extends 'unique' ? Definition[Key] & readonly (readonly ColumnOf<Definition>[])[] : Key extends 'defaults' ? Definition[Key] & DefaultsOf<Definition> : Definition[Key];
|
|
136
145
|
} & Missing<Definition, 'access', AccessIsRequired> & Missing<Definition, 'writes', WritesIsRequired>;
|
|
137
146
|
export declare function defineTables<const Definitions extends Record<string, DraftTable> = Record<never, never>>(definitions: {
|
|
138
147
|
[Key in keyof Definitions]: CheckedTable<Definitions[Key]>;
|
package/dist/table.js
CHANGED
|
@@ -28,6 +28,15 @@ function problemIn(name, definition) {
|
|
|
28
28
|
if (unknown !== undefined) {
|
|
29
29
|
return `Table "${name}" names "${unknown}", which is not one of its columns`;
|
|
30
30
|
}
|
|
31
|
+
for (const [column, value] of Object.entries(definition.defaults ?? {})) {
|
|
32
|
+
const schema = columns[column];
|
|
33
|
+
if (schema === undefined) {
|
|
34
|
+
return `Table "${name}" has a default for "${column}", which is not one of its columns`;
|
|
35
|
+
}
|
|
36
|
+
if (!schema.safeParse(value).success) {
|
|
37
|
+
return `Table "${name}": the default for "${column}" isn't a valid ${column}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
31
40
|
if ((definition.unique ?? []).length > 0 &&
|
|
32
41
|
definition.writes !== 'confirmed') {
|
|
33
42
|
return `Table "${name}" has a unique constraint, so writes must be 'confirmed'`;
|
|
@@ -65,6 +74,7 @@ function table(name, definition, isPatterns = false) {
|
|
|
65
74
|
owned,
|
|
66
75
|
public: publicColumns,
|
|
67
76
|
unique: definition.unique ?? [],
|
|
77
|
+
defaults: definition.defaults ?? {},
|
|
68
78
|
fields: z.object(columns),
|
|
69
79
|
incoming: z.object({ id: z.string(), ...columns }),
|
|
70
80
|
row: z.object({
|