@trypattern/sdk 0.1.24 → 0.1.26

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/app.d.ts CHANGED
@@ -10,6 +10,7 @@ import type { Clock } from './clock.ts';
10
10
  import type { Gate } from './gate.ts';
11
11
  import { ActionsStore } from './actions.ts';
12
12
  import type { Action, ActionsBuilder } from './actions.ts';
13
+ import { Device } from './builtins/Device.ts';
13
14
  import { Toasts } from './builtins/Toasts.ts';
14
15
  import { PageStore } from './page.ts';
15
16
  import type { BuiltInModels } from './registered.ts';
@@ -83,6 +84,7 @@ export type StoresOf<Group extends Tables, Custom, Item extends Action = Action>
83
84
  app: AppStore;
84
85
  actions: ActionsStore<Item>;
85
86
  toasts: Toasts;
87
+ device: Device;
86
88
  page: PageStore;
87
89
  } & TableStores<Group> & PublicStores<Group> & BlockStores<Group> & Custom;
88
90
  type BlockTablesMissing<Name extends string> = StaticTypeError<`The ${Name} block keeps its rows in Pattern's own tables: add them to tables in schema.ts (for bookings, ...bookingTables from '@trypattern/sdk/schema'), then run app migration add_${Name}`>;
package/dist/app.js CHANGED
@@ -10,6 +10,7 @@ import { AppSettings } from "./builtins/AppSettings.js";
10
10
  import { BUILT_IN_GROUPS, builtInActions } from "./builtins/actions.js";
11
11
  import { Palette } from "./builtins/Palette.js";
12
12
  import { SignIn } from "./builtins/SignIn.js";
13
+ import { Device } from "./builtins/Device.js";
13
14
  import { Toasts } from "./builtins/Toasts.js";
14
15
  import { PageStore } from "./page.js";
15
16
  import { assertNotBuilding, snapshotsOf } from "./store.js";
@@ -126,15 +127,16 @@ export function defineApp(definition) {
126
127
  new block.Store(cache.persisted(`stores.${block.name}`), api.services)
127
128
  ]));
128
129
  const toasts = new Toasts(cache.persisted('stores.toasts'), {});
130
+ const device = new Device(cache.persisted('stores.device'), {});
129
131
  const clash = clashOf([
130
- ['session', 'app', 'actions', 'toasts', 'page'],
132
+ ['session', 'app', 'actions', 'toasts', 'device', 'page'],
131
133
  Object.keys(data.tables),
132
134
  Object.keys(data.publicViews),
133
135
  Object.keys(blockStores),
134
136
  Object.keys(custom)
135
137
  ]);
136
138
  if (clash !== undefined) {
137
- throw new Error(`Two stores are called "${clash}". The built-in session, app, actions, toasts and page stores, each table, each table's public view, each block and each store in src/stores/index.ts need their own name.`);
139
+ throw new Error(`Two stores are called "${clash}". The built-in session, app, actions, toasts, device and page stores, each table, each table's public view, each block and each store in src/stores/index.ts need their own name.`);
138
140
  }
139
141
  const actions = new ActionsStore(this.#page, [...(definition.actions ?? []), ...builtInActions], [
140
142
  ...(definition.actionGroups ?? []),
@@ -145,6 +147,7 @@ export function defineApp(definition) {
145
147
  app,
146
148
  actions,
147
149
  toasts,
150
+ device,
148
151
  page: this.#page,
149
152
  ...data.tables,
150
153
  ...data.publicViews,
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ import type { Persisted } from '../state.ts';
3
+ declare const Device_base: import("../store.ts").StoreClass<{
4
+ today: z.ZodDefault<z.ZodString>;
5
+ isCompact: z.ZodDefault<z.ZodBoolean>;
6
+ }, [], Record<never, never>>;
7
+ export declare class Device extends Device_base {
8
+ #private;
9
+ constructor(persisted: Persisted, services: Record<never, never>);
10
+ protected initialState(): {
11
+ today: string;
12
+ isCompact: boolean;
13
+ };
14
+ }
15
+ export {};
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import { defineStore } from "../store.js";
3
+ const Store = defineStore();
4
+ const COMPACT = '(max-width: 767px)';
5
+ function dayOf(at) {
6
+ const month = String(at.getMonth() + 1).padStart(2, '0');
7
+ const day = String(at.getDate()).padStart(2, '0');
8
+ return `${at.getFullYear()}-${month}-${day}`;
9
+ }
10
+ function msUntilTomorrow(at) {
11
+ const tomorrow = new Date(at);
12
+ tomorrow.setHours(24, 0, 1, 0);
13
+ return tomorrow.getTime() - at.getTime();
14
+ }
15
+ function compactQuery() {
16
+ return typeof window === 'undefined' || !('matchMedia' in window)
17
+ ? undefined
18
+ : window.matchMedia(COMPACT);
19
+ }
20
+ export class Device extends Store({
21
+ state: {
22
+ today: z.string().default(''),
23
+ isCompact: z.boolean().default(false)
24
+ }
25
+ }) {
26
+ #midnight;
27
+ constructor(persisted, services) {
28
+ super(persisted, services);
29
+ const query = compactQuery();
30
+ if (query === undefined)
31
+ return;
32
+ query.addEventListener('change', () => this.set({ isCompact: query.matches }));
33
+ const refresh = () => this.#refreshToday();
34
+ window.addEventListener('focus', refresh);
35
+ document.addEventListener('visibilitychange', refresh);
36
+ this.#waitForMidnight();
37
+ }
38
+ initialState() {
39
+ return {
40
+ today: dayOf(new Date()),
41
+ isCompact: compactQuery()?.matches ?? false
42
+ };
43
+ }
44
+ #refreshToday() {
45
+ this.set({ today: dayOf(new Date()) });
46
+ this.#waitForMidnight();
47
+ }
48
+ #waitForMidnight() {
49
+ clearTimeout(this.#midnight);
50
+ this.#midnight = setTimeout(() => this.#refreshToday(), msUntilTomorrow(new Date()));
51
+ }
52
+ }
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
  }
@@ -2,6 +2,7 @@ import type { AppSnapshotShape, ModelServicesOf, NavigateCommand, PressKeyComman
2
2
  import { z } from 'zod';
3
3
  import type { Action, DefineActions } from './actions.ts';
4
4
  import type { ServiceClient, Services } from './services.ts';
5
+ import type { StoreFactory } from './store.ts';
5
6
  import type { Instances, MessagesOf, ModelClassLike, ModelSnapshots } from './model.ts';
6
7
  import type { BUILT_IN_GROUPS } from './builtins/actions.ts';
7
8
  import type { Account } from './builtins/Account.ts';
@@ -48,7 +49,7 @@ export type ActionContext = {
48
49
  };
49
50
  export type ModelServices = ModelServicesOf<AppPath>;
50
51
  export declare const Model: <S extends import("./state.ts").StateSpec = Record<never, never>, const P extends readonly (keyof S & string)[] = []>(config?: import("./model.ts").ModelConfig<S, P>) => import("./model.ts").ModelClass<AppStores, ModelServices, S, P>;
51
- export declare const Store: <S extends import("./state.ts").StateSpec = Record<never, never>, const P extends readonly (keyof S & string)[] = []>(config: import("./store.ts").StoreConfig<S, P>) => import("./store.ts").StoreClass<S, P, ServiceClient<Empty>>;
52
+ export declare const Store: StoreFactory<AppServices>;
52
53
  export declare const defineActions: DefineActions<ActionContext, RegisteredAction, AppCommand>;
53
54
  export declare const appPath: z.ZodCustom<AppPath, AppPath>;
54
55
  type ActionInput = Pick<AppAction, 'id' | 'label' | 'group' | 'icon'> & Partial<Pick<AppAction, 'keys' | 'keywords' | 'isDanger' | 'isSearchOnly' | 'isHidden'>> & {
@@ -378,14 +378,6 @@ function misplacedSourceFiles() {
378
378
  return ownedFiles(SRC_DIRECTORY).filter((path) => !isWhereItBelongs(relative(SRC_DIRECTORY, path)) || !isInItsFolder(path));
379
379
  }
380
380
  const SCHEMA_FILE = join(ROOT, 'schema.ts');
381
- function rowTypeName(table) {
382
- const one = table.endsWith('ies')
383
- ? `${table.slice(0, -3)}y`
384
- : table.endsWith('s')
385
- ? table.slice(0, -1)
386
- : table;
387
- return `${one.charAt(0).toUpperCase()}${one.slice(1)}`;
388
- }
389
381
  function isExported(node) {
390
382
  return (ts.canHaveModifiers(node) &&
391
383
  (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
@@ -403,8 +395,6 @@ function schemaFindings() {
403
395
  return [];
404
396
  const text = readFileSync(SCHEMA_FILE, 'utf8');
405
397
  const source = ts.createSourceFile(SCHEMA_FILE, text, ts.ScriptTarget.Latest, true);
406
- const tables = [];
407
- const rowTypes = new Set();
408
398
  let exportsTables = false;
409
399
  let exportsAdmins = false;
410
400
  const visit = (node) => {
@@ -422,29 +412,6 @@ function schemaFindings() {
422
412
  definesTables(declaration.initializer, source))) {
423
413
  exportsTables = true;
424
414
  }
425
- if (ts.isCallExpression(node) &&
426
- node.expression.getText(source) === 'defineTables') {
427
- const [definitions] = node.arguments;
428
- if (definitions !== undefined &&
429
- ts.isObjectLiteralExpression(definitions)) {
430
- for (const property of definitions.properties) {
431
- const name = property.name;
432
- if (name !== undefined &&
433
- (ts.isIdentifier(name) || ts.isStringLiteral(name))) {
434
- tables.push({ key: name.text, offset: property.getStart(source) });
435
- }
436
- }
437
- }
438
- }
439
- if (ts.isTypeAliasDeclaration(node) &&
440
- isExported(node) &&
441
- ts.isTypeReferenceNode(node.type) &&
442
- node.type.typeName.getText(source) === 'Row') {
443
- const [argument] = node.type.typeArguments ?? [];
444
- if (argument !== undefined && ts.isTypeQueryNode(argument)) {
445
- rowTypes.add(argument.exprName.getText(source));
446
- }
447
- }
448
415
  ts.forEachChild(node, visit);
449
416
  };
450
417
  visit(source);
@@ -456,18 +423,9 @@ function schemaFindings() {
456
423
  }
457
424
  ];
458
425
  }
459
- const missingAdmins = exportsAdmins
426
+ return exportsAdmins
460
427
  ? []
461
428
  : [{ offset: 0, fix: 'export const admins: string[] = []' }];
462
- return [
463
- ...missingAdmins,
464
- ...tables
465
- .filter(({ key }) => !rowTypes.has(`tables.${key}`))
466
- .map(({ key, offset }) => ({
467
- offset,
468
- fix: `export type ${rowTypeName(key)} = Row<typeof tables.${key}>`
469
- }))
470
- ];
471
429
  }
472
430
  const ACCESS_FLOWS_FILE = join(ROOT, 'flows', 'Access.flow.ts');
473
431
  function toasterPlacements() {
@@ -22,7 +22,7 @@ export declare const DEV_ONLY_SIGN_IN_MESSAGE = "The server has exactly one dev-
22
22
  export declare const STATE_VIA_HOOKS_MESSAGE = "A screen reads only its model: const issueList = useModel('issueList'), then issueList.issues, issueList.isLoading and issueList.createIssue(). Reading .state, .models, .stores or getSnapshot() directly does not subscribe, so the screen would show stale data. If the screen needs something from a store, add a getter for it to the model.";
23
23
  export declare const CLASSES_NOT_FACTORIES_MESSAGE = "A function that returns an object of methods is a class in disguise: write it as a class, with private fields for its state and methods for its behaviour. One shape for everything that has state keeps the codebase predictable. Plain functions are for pure helpers that compute a value (sortProjects, newClientSideId).";
24
24
  export declare const ACCESS_FLOWS_MESSAGE = "flows/Access.flow.ts runs Pattern's generated access flows, which prove every table's access rules against the seed on each app test. Keep it: `import { accessFlows } from '@trypattern/sdk/flows'` and `accessFlows({ App })`.";
25
- export declare const SCHEMA_TABLES_MESSAGE = "schema.ts defines every saved table in one place and says who may do what: `export const tables = defineTables({ issues: { columns: { title: z.string() }, access: { read: 'signedIn', create: 'signedIn', update: 'signedIn', delete: 'signedIn' }, writes: 'optimistic' } })`. It exports a row type for each table, named for one row (`export type Issue = Row<typeof tables.issues>`), and the emails of the people who run the app, `export const admins: string[] = []`, even when there are none yet, so the question is answered on purpose. Add the line shown.";
25
+ export declare const SCHEMA_TABLES_MESSAGE = "schema.ts defines every saved table in one place and says who may do what: `export const tables = defineTables({ issues: { columns: { title: z.string() }, access: { read: 'signedIn', create: 'signedIn', update: 'signedIn', delete: 'signedIn' }, writes: 'optimistic' } })`. When another file needs a table's row type, schema.ts exports it, named for one row (`export type Issue = Row<typeof tables.issues>`). It also exports the emails of the people who run the app, `export const admins: string[] = []`, even when there are none yet, so the question is answered on purpose. Add the line shown.";
26
26
  export declare const NO_UNUSED_EXPORTS_MESSAGE = "Export only what another file imports. Nothing imports this, so remove `export` (and the code itself if this file does not use it either). Unused exports hide dead code and make a file look like it offers more than it does. Pattern reads `tables` from schema.ts and `seed` from seed.ts, and vite.config.ts and server/worker.ts default-export their config, so those stay.";
27
27
  export declare const NO_RE_EXPORTS_MESSAGE = "Do not pass imports through: a file exports only what it defines. Import the thing from where it lives instead (command from @trypattern/sdk, a component from its own file), so every import names the real source. server/worker.ts is the one exception: Cloudflare runs the Workflow and Durable Object classes its Worker exports, so it may export them from where they live.";
28
28
  export declare const EXPLICIT_ACCESS_MESSAGE = "Mark every class member public or private (or protected, to override a base class member), so a reader sees at a glance what other code may call: `public setSort = command(...)`, `public override onPageOpen()`, `private loadIssues()`. Use the word private, never #name, so there is one way to say it. Constructors need neither.";
@@ -30,7 +30,7 @@ export declare const IMPORT_FROM_ROOT_MESSAGE = "Import project files by their p
30
30
  export declare const MODEL_MEMBERS_MESSAGE = "A model shows the outside world three kinds of public member, so a reader can tell them apart at a glance: getters are its answers (public get hasNext()), command() fields are what it can do (public openNext = command(() => ...)), and onPageOpen() runs when its page opens. Everything else is private, and a private helper that works something out is a method (private neighbours()), never a getter, because Pattern publishes every getter to the screen and the snapshot. A model has no `actions`: menu entries and shortcuts live in src/actions/.";
31
31
  export declare const MODEL_BY_NAME_MESSAGE = "Keep the model whole and read it by name: const issueList = useModel('issueList'), then issueList.isLoading and onClick={issueList.createIssue}. Destructuring it hides where each value comes from and leaves loose names a reader has to trace back.";
32
32
  export declare const USE_MODEL_WHERE_MESSAGE = "useModel is for screens (src/screens/) and layouts (src/layouts/). Other components take what they show as props and the commands they call as callbacks, so the screen decides what they read: <IssueListRow issue={issue} onDelete={issueList.deleteIssue} />.";
33
- export declare const PURE_GETTERS_MESSAGE = "A getter works an answer out from this.state and this.stores.<name>.state and nothing else. It never changes anything (this.set, a store method, this.app.navigate), never waits, and never reads the clock, randomness or a private field, because Pattern caches each getter until the state it read changes, and flows compare its value. Put the change in a command.";
33
+ export declare const PURE_GETTERS_MESSAGE = "A getter works an answer out from this.state and this.stores.<name>.state and nothing else. It never changes anything (this.set, a store method, this.app.navigate), never waits, and never reads the clock, randomness or a private field, because Pattern caches each getter until the state it read changes, and flows compare its value. Put the change in a command. For the date today read this.stores.device.state.today (YYYY-MM-DD on this device, kept current past midnight), and for a phone-width screen this.stores.device.state.isCompact.";
34
34
  export declare const ACTIONS_WHERE_MESSAGE = "Menu entries and shortcuts are actions, and actions live only in src/actions/: one file per thing they act on, `export const issueActions = defineActions(({ page, models }) => [action({ id: 'issues.next', label: 'Next issue', keys: ['j'], send: [{ type: 'issueDetail.openNext' }] })])`, listed in src/actions/index.ts. If a person can see it in a menu or press a key for it, it is an action; what it does is a command on a model, and an action only sends commands. Models never build actions.";
35
35
  export declare const PURE_ACTIONS_MESSAGE = "An actions file works its entries out from `page` and `models` (each model's state and answers) and nothing else, because Pattern reruns it only when something it read changes, and flows compare the result. It never waits, never uses `this`, never reads the clock or randomness, and imports only types from src/models/, src/stores/ and src/components/. If an entry needs something a model does not answer yet, add a getter to that model.";
36
36
  export declare const USE_PRIMITIVES_MESSAGE = "Write <Button>, <IconButton>, <TextField>, <Select>, <Switch> or <Dialog> from @trypattern/sdk/react instead of this element. The primitives carry the focus, keyboard, disabled, pending and error behaviour and the design system's look, so a screen only says what the control does. If no primitive fits, the gap is in the SDK; do not build around it here.";
@@ -22,7 +22,7 @@ export const DEV_ONLY_SIGN_IN_MESSAGE = 'The server has exactly one dev-only swi
22
22
  export const STATE_VIA_HOOKS_MESSAGE = "A screen reads only its model: const issueList = useModel('issueList'), then issueList.issues, issueList.isLoading and issueList.createIssue(). Reading .state, .models, .stores or getSnapshot() directly does not subscribe, so the screen would show stale data. If the screen needs something from a store, add a getter for it to the model.";
23
23
  export const CLASSES_NOT_FACTORIES_MESSAGE = 'A function that returns an object of methods is a class in disguise: write it as a class, with private fields for its state and methods for its behaviour. One shape for everything that has state keeps the codebase predictable. Plain functions are for pure helpers that compute a value (sortProjects, newClientSideId).';
24
24
  export const ACCESS_FLOWS_MESSAGE = "flows/Access.flow.ts runs Pattern's generated access flows, which prove every table's access rules against the seed on each app test. Keep it: `import { accessFlows } from '@trypattern/sdk/flows'` and `accessFlows({ App })`.";
25
- export const SCHEMA_TABLES_MESSAGE = "schema.ts defines every saved table in one place and says who may do what: `export const tables = defineTables({ issues: { columns: { title: z.string() }, access: { read: 'signedIn', create: 'signedIn', update: 'signedIn', delete: 'signedIn' }, writes: 'optimistic' } })`. It exports a row type for each table, named for one row (`export type Issue = Row<typeof tables.issues>`), and the emails of the people who run the app, `export const admins: string[] = []`, even when there are none yet, so the question is answered on purpose. Add the line shown.";
25
+ export const SCHEMA_TABLES_MESSAGE = "schema.ts defines every saved table in one place and says who may do what: `export const tables = defineTables({ issues: { columns: { title: z.string() }, access: { read: 'signedIn', create: 'signedIn', update: 'signedIn', delete: 'signedIn' }, writes: 'optimistic' } })`. When another file needs a table's row type, schema.ts exports it, named for one row (`export type Issue = Row<typeof tables.issues>`). It also exports the emails of the people who run the app, `export const admins: string[] = []`, even when there are none yet, so the question is answered on purpose. Add the line shown.";
26
26
  export const NO_UNUSED_EXPORTS_MESSAGE = 'Export only what another file imports. Nothing imports this, so remove `export` (and the code itself if this file does not use it either). Unused exports hide dead code and make a file look like it offers more than it does. Pattern reads `tables` from schema.ts and `seed` from seed.ts, and vite.config.ts and server/worker.ts default-export their config, so those stay.';
27
27
  export const NO_RE_EXPORTS_MESSAGE = 'Do not pass imports through: a file exports only what it defines. Import the thing from where it lives instead (command from @trypattern/sdk, a component from its own file), so every import names the real source. server/worker.ts is the one exception: Cloudflare runs the Workflow and Durable Object classes its Worker exports, so it may export them from where they live.';
28
28
  export const EXPLICIT_ACCESS_MESSAGE = 'Mark every class member public or private (or protected, to override a base class member), so a reader sees at a glance what other code may call: `public setSort = command(...)`, `public override onPageOpen()`, `private loadIssues()`. Use the word private, never #name, so there is one way to say it. Constructors need neither.';
@@ -30,7 +30,7 @@ export const IMPORT_FROM_ROOT_MESSAGE = 'Import project files by their path from
30
30
  export const MODEL_MEMBERS_MESSAGE = 'A model shows the outside world three kinds of public member, so a reader can tell them apart at a glance: getters are its answers (public get hasNext()), command() fields are what it can do (public openNext = command(() => ...)), and onPageOpen() runs when its page opens. Everything else is private, and a private helper that works something out is a method (private neighbours()), never a getter, because Pattern publishes every getter to the screen and the snapshot. A model has no `actions`: menu entries and shortcuts live in src/actions/.';
31
31
  export const MODEL_BY_NAME_MESSAGE = "Keep the model whole and read it by name: const issueList = useModel('issueList'), then issueList.isLoading and onClick={issueList.createIssue}. Destructuring it hides where each value comes from and leaves loose names a reader has to trace back.";
32
32
  export const USE_MODEL_WHERE_MESSAGE = 'useModel is for screens (src/screens/) and layouts (src/layouts/). Other components take what they show as props and the commands they call as callbacks, so the screen decides what they read: <IssueListRow issue={issue} onDelete={issueList.deleteIssue} />.';
33
- export const PURE_GETTERS_MESSAGE = 'A getter works an answer out from this.state and this.stores.<name>.state and nothing else. It never changes anything (this.set, a store method, this.app.navigate), never waits, and never reads the clock, randomness or a private field, because Pattern caches each getter until the state it read changes, and flows compare its value. Put the change in a command.';
33
+ export const PURE_GETTERS_MESSAGE = 'A getter works an answer out from this.state and this.stores.<name>.state and nothing else. It never changes anything (this.set, a store method, this.app.navigate), never waits, and never reads the clock, randomness or a private field, because Pattern caches each getter until the state it read changes, and flows compare its value. Put the change in a command. For the date today read this.stores.device.state.today (YYYY-MM-DD on this device, kept current past midnight), and for a phone-width screen this.stores.device.state.isCompact.';
34
34
  export const ACTIONS_WHERE_MESSAGE = "Menu entries and shortcuts are actions, and actions live only in src/actions/: one file per thing they act on, `export const issueActions = defineActions(({ page, models }) => [action({ id: 'issues.next', label: 'Next issue', keys: ['j'], send: [{ type: 'issueDetail.openNext' }] })])`, listed in src/actions/index.ts. If a person can see it in a menu or press a key for it, it is an action; what it does is a command on a model, and an action only sends commands. Models never build actions.";
35
35
  export const PURE_ACTIONS_MESSAGE = "An actions file works its entries out from `page` and `models` (each model's state and answers) and nothing else, because Pattern reruns it only when something it read changes, and flows compare the result. It never waits, never uses `this`, never reads the clock or randomness, and imports only types from src/models/, src/stores/ and src/components/. If an entry needs something a model does not answer yet, add a getter to that model.";
36
36
  export const USE_PRIMITIVES_MESSAGE = "Write <Button>, <IconButton>, <TextField>, <Select>, <Switch> or <Dialog> from @trypattern/sdk/react instead of this element. The primitives carry the focus, keyboard, disabled, pending and error behaviour and the design system's look, so a screen only says what the control does. If no primitive fits, the gap is in the SDK; do not build around it here.";
@@ -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/store.d.ts CHANGED
@@ -34,7 +34,8 @@ export declare abstract class DeclaredStore<State extends object, Client = Recor
34
34
  export type StoreClass<S extends StateSpec, P extends readonly (keyof S & string)[], Client = Record<never, never>> = (abstract new (persisted: Persisted, services: Client) => DeclaredStore<StateOf<S>, Client>) & {
35
35
  readonly config: StoreConfig<S, P>;
36
36
  };
37
- export declare function defineStore<Client>(): <S extends StateSpec = Record<never, never>, const P extends readonly (keyof S & string)[] = []>(config: StoreConfig<S, P>) => StoreClass<S, P, Client>;
37
+ export type StoreFactory<Client> = <S extends StateSpec = Record<never, never>, const P extends readonly (keyof S & string)[] = []>(config: StoreConfig<S, P>) => StoreClass<S, P, Client>;
38
+ export declare function defineStore<Client>(): StoreFactory<Client>;
38
39
  export type Snapshots<Group> = {
39
40
  [Key in keyof Group]: Group[Key] extends {
40
41
  readonly state: infer State;
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.26",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",