@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 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
- A new required column starts as empty, 0 or false on rows that already exist (the live site has rows),
221
- and a column made required fills its empty rows the same way; each is noted. To start them on a
222
- real value, add an UPDATE after it in the migration file.`,
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 notes = code === 0 ? startNewColumns(name) : [];
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
- output: output.trimEnd(),
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 newestMigration(name) {
1315
- return readdirSync(MIGRATIONS_DIR)
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 previousTables(file) {
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(JSON.parse(readText(journal))).entries;
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 = join(MIGRATIONS_DIR, 'meta', `${String(before.idx).padStart(4, '0')}_snapshot.json`);
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, notes } = withStartingValues(readText(path), tables);
1354
- writeFileSync(path, sql);
1355
- return notes.map((note) => `${file}: ${note}`);
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
- notes: string[];
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 REQUIRED = /^(\s*`(\w+)` (text|integer|real)) NOT NULL(,?)$/;
4
- const STARTING_VALUES = {
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 started = [];
19
- const added = sql.replaceAll(ADDED, (_line, table, column, type) => {
20
- started.push({ table, column, type });
21
- return `ALTER TABLE \`${table}\` ADD \`${column}\` ${type} NOT NULL DEFAULT ${STARTING_VALUES[type] ?? "''"};`;
22
- });
23
- const rebuilt = added.replaceAll(REBUILT, (whole, table, body, between, into, from) => {
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 types = new Map();
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 (type === undefined)
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
- if (!before.notNull) {
47
- started.push({ table, column, type });
48
- return `coalesce("${column}", ${value})`;
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}\` (${lines.join('\n')}\n);${between}INSERT INTO \`__new_${table}\`(${into}) SELECT ${selected.join(', ')} FROM \`${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
  }
@@ -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
- return types.includes('null') ? column : column.notNull();
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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trypattern/sdk",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",