@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 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 ownerBackfillWarning(name) {
1306
- const file = readdirSync(MIGRATIONS_DIR)
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
+ }
@@ -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.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",