@trypattern/sdk 0.1.23 → 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 +83 -3
- package/dist/cli/startingValues.d.ts +13 -0
- package/dist/cli/startingValues.js +48 -0
- 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
|
@@ -22,6 +22,7 @@ import { readStarter, starterContentOf, withStarter } from "./starter.js";
|
|
|
22
22
|
import { addLiveMedia, login, publish, pull, restore, versions as listVersions } from "./publishing.js";
|
|
23
23
|
import { CHANGE_SET_ROWS } from "../publishWire.js";
|
|
24
24
|
import { DATA_USAGE, data } from "./data.js";
|
|
25
|
+
import { withStartingValues } from "./startingValues.js";
|
|
25
26
|
import { toolBin } from "./tools.js";
|
|
26
27
|
import { isNewer, latestPattern, newerPattern, updateNote } from "./updates.js";
|
|
27
28
|
function dependsOnPattern(directory) {
|
|
@@ -215,7 +216,10 @@ changing seed.ts or starter/. Works while the dev server runs; reload the page t
|
|
|
215
216
|
details: `app migration add_due_dates
|
|
216
217
|
Compares the tables in schema.ts (plus Pattern's users, sessions and settings tables) with server/migrations and writes the
|
|
217
218
|
SQL that moves the database from one to the other, named <number>_<name>.sql. app start applies new
|
|
218
|
-
migrations to the local database. Prints "No schema changes" when the migrations already match
|
|
219
|
+
migrations to the local database. Prints "No schema changes" when the migrations already match.
|
|
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.`,
|
|
219
223
|
run: migration,
|
|
220
224
|
takesArgument: true
|
|
221
225
|
},
|
|
@@ -1295,6 +1299,16 @@ async function migration(name) {
|
|
|
1295
1299
|
'--name',
|
|
1296
1300
|
name
|
|
1297
1301
|
]);
|
|
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
|
+
}
|
|
1298
1312
|
const warning = code === 0 ? ownerBackfillWarning(name) : undefined;
|
|
1299
1313
|
return {
|
|
1300
1314
|
code,
|
|
@@ -1302,11 +1316,77 @@ async function migration(name) {
|
|
|
1302
1316
|
text: `${code === 0 ? '✓' : '✗'} app migration ${name}\n\n${indent(output.trimEnd())}${warning === undefined ? '' : `\n\n! ${warning}`}`
|
|
1303
1317
|
};
|
|
1304
1318
|
}
|
|
1305
|
-
function
|
|
1306
|
-
|
|
1319
|
+
function camelCase(name) {
|
|
1320
|
+
return name.replaceAll(/_([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
|
|
1321
|
+
}
|
|
1322
|
+
function journalEntries() {
|
|
1323
|
+
const journal = join(MIGRATIONS_DIR, 'meta', '_journal.json');
|
|
1324
|
+
if (!existsSync(journal))
|
|
1325
|
+
return undefined;
|
|
1326
|
+
const raw = JSON.parse(readText(journal));
|
|
1327
|
+
const entries = z
|
|
1328
|
+
.object({
|
|
1329
|
+
entries: z.array(z.object({ idx: z.number(), tag: z.string() }))
|
|
1330
|
+
})
|
|
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)
|
|
1307
1351
|
.filter((entry) => entry.endsWith(`_${name}.sql`))
|
|
1308
1352
|
.sort()
|
|
1309
1353
|
.at(-1);
|
|
1354
|
+
}
|
|
1355
|
+
function previousTables(file) {
|
|
1356
|
+
const entries = journalEntries()?.entries ?? [];
|
|
1357
|
+
const at = entries.findIndex((entry) => `${entry.tag}.sql` === file);
|
|
1358
|
+
const before = at > 0 ? entries[at - 1] : undefined;
|
|
1359
|
+
if (before === undefined)
|
|
1360
|
+
return {};
|
|
1361
|
+
const snapshot = snapshotPath(before.idx);
|
|
1362
|
+
if (!existsSync(snapshot))
|
|
1363
|
+
return {};
|
|
1364
|
+
return z
|
|
1365
|
+
.object({
|
|
1366
|
+
tables: z.record(z.string(), z.object({
|
|
1367
|
+
columns: z.record(z.string(), z.object({ notNull: z.boolean() }))
|
|
1368
|
+
}))
|
|
1369
|
+
})
|
|
1370
|
+
.parse(JSON.parse(readText(snapshot))).tables;
|
|
1371
|
+
}
|
|
1372
|
+
function startNewColumns(name) {
|
|
1373
|
+
const file = newestMigration(name);
|
|
1374
|
+
if (file === undefined)
|
|
1375
|
+
return [];
|
|
1376
|
+
const path = join(MIGRATIONS_DIR, file);
|
|
1377
|
+
const tables = Object.fromEntries(Object.entries(previousTables(file)).map(([table, { columns }]) => [
|
|
1378
|
+
table,
|
|
1379
|
+
columns
|
|
1380
|
+
]));
|
|
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;
|
|
1387
|
+
}
|
|
1388
|
+
function ownerBackfillWarning(name) {
|
|
1389
|
+
const file = newestMigration(name);
|
|
1310
1390
|
if (file === undefined)
|
|
1311
1391
|
return undefined;
|
|
1312
1392
|
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type Columns = Readonly<Record<string, {
|
|
2
|
+
notNull: boolean;
|
|
3
|
+
}>>;
|
|
4
|
+
export type PreviousTables = Readonly<Record<string, Columns>>;
|
|
5
|
+
export type Unstarted = {
|
|
6
|
+
table: string;
|
|
7
|
+
column: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function withStartingValues(sql: string, previous: PreviousTables): {
|
|
10
|
+
sql: string;
|
|
11
|
+
unstarted: Unstarted[];
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const ADDED = /^ALTER TABLE `(\w+)` ADD `(\w+)` (?:text|integer|real) NOT NULL;/gm;
|
|
2
|
+
const REBUILT = /CREATE TABLE `__new_(\w+)` \(([^;]*?)\n\);(\n(?:--> statement-breakpoint\n)?\n?)INSERT INTO `__new_\1`\(([^)]*)\) SELECT ([^;]*) FROM `\1`;/g;
|
|
3
|
+
const COLUMN = /^\s*`(\w+)` (?:text|integer|real)(.*)$/;
|
|
4
|
+
const STARTS_AS = / DEFAULT ('(?:[^']|'')*'|[-\w.]+)/;
|
|
5
|
+
function quoted(names) {
|
|
6
|
+
return names.split(',').map((name) => name.trim().replaceAll('"', ''));
|
|
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
|
+
}
|
|
21
|
+
export function withStartingValues(sql, previous) {
|
|
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) => {
|
|
29
|
+
const old = previous[table];
|
|
30
|
+
if (old === undefined)
|
|
31
|
+
return whole;
|
|
32
|
+
const starts = startsOf(body);
|
|
33
|
+
const selected = quoted(from).map((column) => {
|
|
34
|
+
const before = old[column];
|
|
35
|
+
if (!starts.has(column) || before?.notNull === true) {
|
|
36
|
+
return `"${column}"`;
|
|
37
|
+
}
|
|
38
|
+
const start = starts.get(column) ?? null;
|
|
39
|
+
if (start === null) {
|
|
40
|
+
unstarted.push({ table, column });
|
|
41
|
+
return `"${column}"`;
|
|
42
|
+
}
|
|
43
|
+
return before === undefined ? start : `coalesce("${column}", ${start})`;
|
|
44
|
+
});
|
|
45
|
+
return `CREATE TABLE \`__new_${table}\` (${body}\n);${between}INSERT INTO \`__new_${table}\`(${into}) SELECT ${selected.join(', ')} FROM \`${table}\`;`;
|
|
46
|
+
});
|
|
47
|
+
return { sql: rebuilt, unstarted };
|
|
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({
|