@trypattern/sdk 0.1.11 → 0.1.13

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.
Files changed (87) hide show
  1. package/README.md +73 -12
  2. package/dist/access.d.ts +0 -4
  3. package/dist/access.js +0 -5
  4. package/dist/api.d.ts +3 -1
  5. package/dist/api.js +5 -17
  6. package/dist/app.d.ts +20 -5
  7. package/dist/app.js +32 -2
  8. package/dist/blocks.d.ts +18 -0
  9. package/dist/blocks.js +36 -0
  10. package/dist/bookings.d.ts +400 -0
  11. package/dist/bookings.js +300 -0
  12. package/dist/bookingsMemory.d.ts +9 -0
  13. package/dist/bookingsMemory.js +79 -0
  14. package/dist/bookingsStore.d.ts +698 -0
  15. package/dist/bookingsStore.js +129 -0
  16. package/dist/cli/app.js +143 -59
  17. package/dist/cli/publishing.d.ts +3 -1
  18. package/dist/cli/publishing.js +33 -5
  19. package/dist/cli/starter.d.ts +17 -0
  20. package/dist/cli/starter.js +140 -0
  21. package/dist/components/Button.d.ts +2 -24
  22. package/dist/components/Button.js +4 -251
  23. package/dist/components/ButtonBody.d.ts +25 -0
  24. package/dist/components/ButtonBody.js +266 -0
  25. package/dist/components/Calendar.d.ts +20 -0
  26. package/dist/components/Calendar.js +195 -0
  27. package/dist/components/Card.d.ts +1 -1
  28. package/dist/components/Card.js +7 -29
  29. package/dist/components/Choice.js +27 -13
  30. package/dist/components/ChoiceGroup.d.ts +3 -1
  31. package/dist/components/ChoiceGroup.js +3 -4
  32. package/dist/components/ContinueWithGoogle.d.ts +11 -0
  33. package/dist/components/ContinueWithGoogle.js +5 -0
  34. package/dist/components/Dialog.d.ts +1 -1
  35. package/dist/components/Dialog.js +3 -1
  36. package/dist/components/Icon.js +1 -1
  37. package/dist/components/ListRow.js +2 -1
  38. package/dist/components/RadioGroup.d.ts +2 -1
  39. package/dist/components/RadioGroup.js +10 -2
  40. package/dist/components/Text.d.ts +1 -1
  41. package/dist/components/Text.js +43 -2
  42. package/dist/components/calendarDates.d.ts +14 -0
  43. package/dist/components/calendarDates.js +84 -0
  44. package/dist/components/controlLook.js +11 -11
  45. package/dist/components/icons.d.ts +9 -0
  46. package/dist/components/icons.js +17 -1
  47. package/dist/components/interaction.d.ts +1 -1
  48. package/dist/flows/browser.d.ts +7 -1
  49. package/dist/flows/browser.js +25 -7
  50. package/dist/flows/flowBrowser.d.ts +2 -2
  51. package/dist/flows/flowBrowser.js +2 -2
  52. package/dist/flows/flows.js +8 -2
  53. package/dist/index.d.ts +4 -1
  54. package/dist/index.js +1 -0
  55. package/dist/media.d.ts +11 -1
  56. package/dist/patternAccess.d.ts +11 -2
  57. package/dist/patternAccess.js +8 -4
  58. package/dist/publishWire.d.ts +24 -2
  59. package/dist/publishWire.js +22 -2
  60. package/dist/react.d.ts +3 -0
  61. package/dist/react.js +2 -0
  62. package/dist/rules/checkAllFiles.js +16 -2
  63. package/dist/rules/lintPlugin.js +1 -0
  64. package/dist/schema.d.ts +3 -1
  65. package/dist/schema.js +1 -0
  66. package/dist/server/bookings.d.ts +12 -0
  67. package/dist/server/bookings.js +142 -0
  68. package/dist/server/createServer.d.ts +3 -2
  69. package/dist/server/createServer.js +13 -4
  70. package/dist/server/env.d.ts +8 -0
  71. package/dist/server/index.d.ts +3 -1
  72. package/dist/server/index.js +1 -0
  73. package/dist/server/media.d.ts +16 -3
  74. package/dist/server/media.js +72 -23
  75. package/dist/server/patternViewer.js +16 -10
  76. package/dist/server/tables.d.ts +2 -0
  77. package/dist/server/tables.js +17 -1
  78. package/dist/start.js +12 -0
  79. package/dist/table.js +9 -3
  80. package/dist/theme/scales.d.ts +1 -1
  81. package/dist/theme/scales.js +1 -0
  82. package/dist/theme/tokens.stylex.d.ts +18 -0
  83. package/dist/theme/tokens.stylex.js +19 -1
  84. package/dist/vite/localWorker.d.ts +2 -0
  85. package/dist/vite/localWorker.js +48 -3
  86. package/dist/vite/vite.js +5 -1
  87. package/package.json +1 -1
package/README.md CHANGED
@@ -73,10 +73,11 @@ export default createServer({ tables, admins })
73
73
  `session`, `actions`, `toasts` and `page` stores, with the appearance, Log out and ⌘K actions), `defineRoutes` (route ids are the page names), `startApp` (the browser entry:
74
74
  creates the router, with the dev tools `app send` and visual flows use), `Model`, `Store`,
75
75
  `command`, `appPath`, `defineActions`, `action`, `rankActions` (the command menu's search),
76
- `EntityStore`, `TableStore` (`create` takes the columns only), `Cache`, `Gate`, and the types
76
+ `EntityStore`, `TableStore` (`create` takes the columns only), `Cache`, `Gate`, the `bookings`
77
+ block (below), and the types
77
78
  `AppStores`, `AppSnapshot`, `AppCommand`, `AppPath`, `AppAction`, `AppActionGroup`,
78
79
  `Interaction`, `PublishedOf`, `MessagesOf`.
79
- - `@trypattern/sdk/schema`: `defineTables` and the types `Row`, `PublicRow` and `Seed`. Every row gets an
80
+ - `@trypattern/sdk/schema`: `defineTables` and the types `Row`, `PublicRow`, `Seed` and `Starter`. Every row gets an
80
81
  `id` and `createdAt` that Pattern sets. Every table declares `access` (`anyone`, `signedIn`,
81
82
  `owner`, `admin` or `server` for read, create, update and delete) and `writes` (`optimistic` or
82
83
  `confirmed`), and may add `owned: true` (an `ownerId` the server fills), `public` columns (a
@@ -84,7 +85,19 @@ export default createServer({ tables, admins })
84
85
  own table of photos (`key`, `width`, `height`, `placeholder`, `alt`, `type`, `bytes`): an app
85
86
  that shows photos adds it to its tables (`{ ...defineTables({ … }), media }`), binds an R2
86
87
  bucket as `MEDIA` in `wrangler.jsonc` with `"/media/*"` in `run_worker_first`, and stores the
87
- media row's `id` in its own columns. Anyone reads it; only the server creates rows. `src/access.ts` is
88
+ media row's `id` in its own columns. Anyone reads it; only the server creates rows.
89
+ `bookingTables` are the bookings block's own tables (`bookingRules`, one row of weekday windows
90
+ `{ weekday, label, start, end, capacity }` with `leadTimeHours`, `daysAhead` and `autoAccept`;
91
+ `bookingBlackouts`, `{ date, reason }`; `bookingRequests`, `{ offering, date, window, name,
92
+ email, phone, address, notes, status }` with `status` `requested`, `accepted`, `declined` or
93
+ `cancelled`): an app that takes bookings spreads them into its tables
94
+ (`{ ...defineTables({ … }), ...bookingTables }`) so migrations, the seed and the dashboard's
95
+ database see them, and every operation on them is `server`. `seed.ts` is
96
+ sample data for dev and flows and never ships; `starter/` is the content a new site opens with,
97
+ written once on its first publish, then owned by the owner. `starter/rows.ts` exports
98
+ `starter: Starter<typeof tables>`: rows for tables that are not `owned`, and `media` rows as
99
+ `{ id, photo, alt }`, where `photo` names a file in `starter/photos/`. After that first publish
100
+ the folder is inert and may be deleted. `src/access.ts` is
88
101
  the one rule engine; the Worker and the flow runner's memory API both use it, and
89
102
  `accessFlows({ App })` from `@trypattern/sdk/flows` checks every table's rules against the seed.
90
103
  Store writes return `'saved' | 'conflict' | 'forbidden' | 'missing' | 'failed'`. Safe to import
@@ -93,11 +106,15 @@ export default createServer({ tables, admins })
93
106
  auth.trypattern.ai (Google, and emailed 6-digit codes; locally the code prints in the dev log),
94
107
  dev sign-in, sessions, `/api/me` and every table under `/api/tables`. A deployed app holds one
95
108
  `AUTH_SECRET` that Pattern's deploy sets, and no Google secrets. Also `users`, `sessions`,
96
- With `media` in its tables it also serves `POST /api/media` (admins only, a form with `file`
97
- and `alt`, up to 10 MB of JPEG, PNG, WebP, GIF or AVIF; HEIC is refused): it reads the size from
109
+ With `media` in its tables it also serves `POST /api/media` (admins only, or Pattern with a
110
+ `media:write` assertion; a form with `file`, `alt` and an optional `id`, which makes a repeat
111
+ answer 200 with the row already there; up to 10 MB of JPEG, PNG, WebP, GIF or AVIF; HEIC is
112
+ refused): it reads the size from
98
113
  the file's header (EXIF rotation included), stores it in `MEDIA` as `images/<sha256-16>.<ext>`,
99
114
  asks the zone's image transformations for a 24-pixel JPEG placeholder (skipped in dev, so it is
100
- `null` locally) and adds the row; and `GET /media/<key>`, immutable with an ETag. With
115
+ `null` locally) and adds the row; and `GET /media/<key>`, immutable with an ETag, which in dev
116
+ fetches a photo the local bucket lacks from `https://<name>.onpattern.app`, so a pulled project
117
+ shows the live photos without storing them. With
101
118
  `pages` (`definePages(tables, { '/': head, '/items/:itemId': { head, paths } })` in
102
119
  `server/pages.ts`) it renders the `<head>` of public pages: each `head` reads tables anyone may
103
120
  read (`rows`, `row`, `photoUrl`) and returns `{ title, description, image, hero, jsonLd, data }`
@@ -108,7 +125,23 @@ export default createServer({ tables, admins })
108
125
  and the alt as caption) and `robots.txt` come from the same list (`paths` lists a pattern's
109
126
  params). It needs `"binding": "ASSETS"` and the page paths, `/sitemap.xml` and `/robots.txt` in
110
127
  `run_worker_first`; other paths still get the built files. `localBusiness({ … })` and
111
- `product({ … })` write the JSON-LD. Also `users`, `sessions`,
128
+ `product({ … })` write the JSON-LD. With
129
+ `blocks: [bookings({ timezone: 'America/Toronto' })]` (an IANA zone; the owner's days and
130
+ windows are in it) it serves the bookings block under `/api/bookings`, open to signed-out
131
+ visitors: `GET days?month=2026-11` (every day of the month as `open`, `closed` or `full`),
132
+ `GET windows?date=2026-11-13` (that day's bookable windows, `{ id, label, start, end,
133
+ remaining }`, the `id` being `start-end`), and `POST requests` (public; it checks again that the
134
+ day and window are bookable, then inserts only while the window's `requested` and `accepted`
135
+ requests are under its capacity, in one statement, so two visitors never both get the last place:
136
+ 400 for a day or window that can't be booked, 409 when it just filled). A day is `closed` before
137
+ today, after `daysAhead`, on a blackout, or when none of its weekday's windows starts at least
138
+ `leadTimeHours` from now; `full` when every window left has no room. The owner's routes need an
139
+ admin: `GET requests`, `POST requests/:id/accept` (from `requested`), `POST requests/:id/decline`
140
+ (from `requested` or `accepted`, which frees the place), `GET rules` and `POST rules` (windows,
141
+ lead time, days ahead, `autoAccept` and the blackouts together). Emails to the owner and the
142
+ requester are not sent yet: `sendBookingEmail` in `src/server/bookings.ts` is the one place they
143
+ will go, and it prints each one in the dev log. `createServer` refuses a block whose tables are
144
+ missing from `tables`, and booking tables without the block. Also `users`, `sessions`,
112
145
  `sqlTables`, `seedSql`, the sign-in wire schemas (`verifiedIdentity`, `exchangeRequest`,
113
146
  `exchangeReply`, `sendCodeRequest`, `sendCodeReply`, `verifyCodeRequest`, `verifyCodeReply`) and
114
147
  the Worker types `D1Database` and `R2Bucket`.
@@ -121,14 +154,21 @@ export default createServer({ tables, admins })
121
154
  (`multiline` with `rows` for a few lines), `Select`, `Switch`,
122
155
  `SegmentedControl`, `ChoiceGroup` (a wrapping radio group of `Choice` children, each with a
123
156
  `title`, `subline`, `isDisabled` or any children such as an `Image` with a `label`; the chosen
124
- one is ringed, the others fade, arrow keys move between them), `Menu`, `ContextMenu`, `Dialog`, `Tooltip`, `Kbd`, `ScrollArea`, `Avatar`,
125
- `Badge`, `Skeleton`, `Spinner`, `Icon`. Photos: `Image` (`image` is a media row, `sizes` is
157
+ one is ringed, unchosen photos fade, arrow keys move between them), `Calendar` (a month grid of `open`, `closed` and `full` days, with month arrows and arrow-key movement), `Menu`, `ContextMenu`, `Dialog` (`size` up to `xl`, 800 pixels), `Tooltip`, `Kbd`, `ScrollArea`, `Avatar`,
158
+ `Badge`, `Skeleton`, `Spinner`, `Icon`. Dates: `Calendar` (a month grid; the caller owns the
159
+ month: `month="2026-11"` and `onMonthChange`, with `firstMonth` and `lastMonth` to stop the
160
+ arrows; `days` lists `{ date, state }` with `open`, `closed` (greyed) or `full` (struck
161
+ through), and a day it does not list is closed; `value` is filled and `onPick` gets an open day;
162
+ today, from the browser or `today`, is ringed; weeks start on Monday and the month and weekday
163
+ names follow `locale`; arrow keys move by day and week, Home and End to the week's ends, PageUp
164
+ and PageDown by month, moving the month with them). Photos: `Image` (`image` is a media row, `sizes` is
126
165
  required, `priority` for the first photo on a page only, `aspect` crops with `fit=cover`): it
127
166
  reserves the size from the row's `width` and `height`, offers 320 to 1920 pixel widths (never
128
167
  wider than the original) through `/cdn-cgi/image/…` (plain `/media/<key>` on localhost), lazy-loads
129
168
  unless `priority`, shows the blurred placeholder and fades the photo in over 200 ms unless it was
130
169
  already loaded or motion is reduced, and shows a neutral block when the photo is missing or fails. Types: `IconName`, `MenuEntry`, `SidebarLink`,
131
- `Toast`, `SelectOption`, `SegmentedOption`, `CommandMenuItem`, `SpaceName` and `Placement`.
170
+ `Toast`, `SelectOption`, `SegmentedOption`, `CommandMenuItem`, `CalendarDay`, `SpaceName` and
171
+ `Placement`.
132
172
  Everything takes closed choices (`variant`, `tone`, `size`, `gap`, `width`) and a `style`
133
173
  limited to placement, and every interactive primitive reports to `startApp`'s `onInteraction`.
134
174
  App screens have no styles; `app check` holds them to that.
@@ -141,6 +181,22 @@ export default createServer({ tables, admins })
141
181
  the app tsconfig preset loads, fills `Register` from `src/App.ts`, `schema.ts`,
142
182
  `src/stores/index.ts` and `src/routes.tsx`. `Model`, `AppStores`, `AppSnapshot`, `AppCommand` and `AppPath` come from `@trypattern/sdk`.
143
183
 
184
+ - Blocks: an SDK module with its own tables, server routes and store, and no look; the app's
185
+ components draw it. The first is `bookings`, request-and-accept with windows and capacity:
186
+ `createServer({ tables, admins, blocks: [bookings({ timezone })] })` in `server/worker.ts` and
187
+ `defineApp({ tables, blocks: [bookings], stores, models, routes })` in `src/App.ts`, with
188
+ `...bookingTables` in `tables`. Every model then has `this.stores.bookings`, whose calls
189
+ (`bookings.days`, `bookings.windows`, `bookings.request`, `bookings.requests`, `bookings.accept`,
190
+ `bookings.decline`, `bookings.rules`, `bookings.saveRules`) go through the Gate:
191
+ `loadDays(month)` and `loadWindows(date)` fill `days[month]` and `windows[date]`;
192
+ `request({ offering, date, window, name, email, phone?, address?, notes? })` sets `sent` and
193
+ reloads that month and date; `loadRequests()` fills `requests` and `requestsStatus`;
194
+ `accept(id)`, `decline(id)`, `loadRules()` and `saveRules(rules)` keep `requests` and `rules`
195
+ current. Writes return `'saved' | 'conflict' | 'forbidden' | 'missing' | 'failed'` like table
196
+ writes. `offering` is any id the app gives it (an item, a size); the block never owns products.
197
+ In flows and `?memory` the calls fake against `world.bookings` (`BookingsWorld`: `now`,
198
+ `timezone`, `rules`, `requests`), with the same availability code as the Worker; the fake does
199
+ not check who is signed in. A type error names a block whose tables are not in `tables`.
144
200
  - `@trypattern/sdk/flows`: `defineFlow({ App })`; the `flow()` it returns fakes the server
145
201
  from the app's tables and the seed in `vite.config.ts`, and runs headless in Node or in Chrome with `app test --visual`.
146
202
  - `@trypattern/sdk/vite`: `pattern({ tables, admins, seed })`, the whole Vite and Vitest setup, including the
@@ -151,7 +207,12 @@ export default createServer({ tables, admins })
151
207
  and its icons. Pattern's dashboard renders the design system from it.
152
208
  - `@trypattern/sdk/tsconfig/app.json` and `/tsconfig/server.json`: the presets project tsconfigs extend.
153
209
  - The `app` command: start, check, fix, reset, migration, media (`app media add photo.jpg --alt "…"`
154
- uploads to the dev server as the first admin), build, send, screenshot, test, sign-in, setup.
210
+ uploads to the dev server as the first admin; `--live` first uploads it to the live site through
211
+ onpattern.app, then locally under the same id), build, send, screenshot, test, sign-in, setup,
212
+ login, pull, publish, versions, restore. `app start` and `app reset` fill the local database
213
+ from `seed.ts` and `starter/rows.ts` and put `starter/photos` in the local `MEDIA` bucket;
214
+ `app publish --starter` sends `starter/` with the starter, and a new site's first publish adds
215
+ the photos through its own `POST /api/media`, then the rows.
155
216
 
156
217
  Published to npm as `@trypattern/sdk`, compiled to `dist/`; in this repo the blueprint runs the
157
- source directly. Still to come: payments, workflows and blocks.
218
+ source directly. Still to come: payments, workflows and more blocks.
package/dist/access.d.ts CHANGED
@@ -16,10 +16,6 @@ export declare function viewerOf(user: {
16
16
  id: string;
17
17
  isAdmin: boolean;
18
18
  } | null | undefined): Viewer | null;
19
- export declare function viewerByEmail(user: {
20
- id: string;
21
- email: string;
22
- } | null | undefined, admins: ReadonlySet<string>): Viewer | null;
23
19
  export declare function readScope(table: Table, viewer: Viewer | null): ReadScope;
24
20
  export declare function listRefusal(table: Table, viewer: Viewer | null): Refusal | undefined;
25
21
  export declare function canSee(table: Table, viewer: Viewer | null, row: Owned): boolean;
package/dist/access.js CHANGED
@@ -6,11 +6,6 @@ export function viewerOf(user) {
6
6
  return null;
7
7
  return { userId: user.id, isAdmin: user.isAdmin };
8
8
  }
9
- export function viewerByEmail(user, admins) {
10
- if (user === null || user === undefined)
11
- return null;
12
- return { userId: user.id, isAdmin: admins.has(user.email.toLowerCase()) };
13
- }
14
9
  function mayUse(level, viewer) {
15
10
  if (level === 'server')
16
11
  return false;
package/dist/api.d.ts CHANGED
@@ -24,7 +24,9 @@ export declare function memoryApi<Group extends Tables>(tables: Group, seed: See
24
24
  admins: readonly string[];
25
25
  title: string;
26
26
  } & ServicesSetup): AppApi<Group>;
27
- export declare function browserApi<Group extends Tables>(tables: Group, seed: Seed<Group>, gate: Gate, { inMemory, admins, ...setup }: {
27
+ export declare function browserApi<Group extends Tables>(tables: Group, seed: Seed<Group>, gate: Gate, { inMemory, signedInAs, admins, title, ...setup }: {
28
28
  inMemory: boolean;
29
+ signedInAs: string | null;
29
30
  admins: readonly string[];
31
+ title: string;
30
32
  } & ServicesSetup): AppApi<Group>;
package/dist/api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { adminEmails, viewerByEmail, viewerOf } from "./access.js";
1
+ import { viewerOf } from "./access.js";
2
2
  import { HttpSessionApi, MemorySessionApi, sessionCalls } from "./auth/sessionApi.js";
3
3
  import { HttpAppInfoApi, MemoryAppInfoApi, appInfoCalls } from "./appInfo.js";
4
4
  import { httpServices, memoryServices } from "./serviceClients.js";
@@ -41,20 +41,8 @@ export function memoryApi(tables, seed, { gate, signedInAs, admins, title, ...se
41
41
  services: memoryClient(setup, options)
42
42
  };
43
43
  }
44
- export function browserApi(tables, seed, gate, { inMemory, admins, ...setup }) {
45
- const session = new HttpSessionApi(gate);
46
- const emails = adminEmails(admins);
47
- const options = { latencyMs: 0, gate };
48
- return {
49
- tables: inMemory
50
- ? memoryTables(tables, seed, () => viewerByEmail(session.rememberedUser(), emails), { latencyMs: 0, gate })
51
- : httpTables(tables, gate, () => session.reportSignedOut()),
52
- session,
53
- app: new HttpAppInfoApi(gate),
54
- services: inMemory
55
- ? memoryClient(setup, options)
56
- : setup.services === undefined
57
- ? {}
58
- : httpServices(setup.services, gate, () => session.reportSignedOut())
59
- };
44
+ export function browserApi(tables, seed, gate, { inMemory, signedInAs, admins, title, ...setup }) {
45
+ return inMemory
46
+ ? memoryApi(tables, seed, { gate, signedInAs, admins, title, ...setup })
47
+ : httpApi(tables, gate, setup);
60
48
  }
package/dist/app.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { DataRouter } from 'react-router';
2
2
  import type { AppApi } from './api.ts';
3
3
  import { AppStore } from './appInfo.ts';
4
+ import type { AppBlock } from './blocks.ts';
5
+ import type { bookingRequests } from './bookings.ts';
6
+ import type { Bookings } from './bookingsStore.ts';
4
7
  import { SessionStore } from './auth/sessionStore.ts';
5
8
  import type { Cache } from './cache.ts';
6
9
  import type { Clock } from './clock.ts';
@@ -70,15 +73,27 @@ export type PublicStores<Group extends Tables> = {
70
73
  never
71
74
  ] ? never : `public${Capitalize<Key>}`]: PublicTableStore<Group[Key]>;
72
75
  };
76
+ export type BlockStores<Group extends Tables> = [
77
+ typeof bookingRequests
78
+ ] extends [Group[keyof Group]] ? {
79
+ bookings: Bookings;
80
+ } : Record<never, never>;
73
81
  export type StoresOf<Group extends Tables, Custom, Item extends Action = Action> = {
74
82
  session: SessionStore;
75
83
  app: AppStore;
76
84
  actions: ActionsStore<Item>;
77
85
  toasts: Toasts;
78
86
  page: PageStore;
79
- } & TableStores<Group> & PublicStores<Group> & Custom;
80
- export type AppDefinition<Group extends Tables, Custom extends StoreClasses, Classes extends Record<string, ModelClassLike>, Routes extends readonly RouteDefinition[]> = {
87
+ } & TableStores<Group> & PublicStores<Group> & BlockStores<Group> & Custom;
88
+ 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}`>;
89
+ export type CheckedBlocks<Group extends Tables, Blocks extends readonly AppBlock[]> = {
90
+ [Key in keyof Blocks]: [
91
+ Blocks[Key]['tables'][keyof Blocks[Key]['tables']]
92
+ ] extends [Group[keyof Group]] ? Blocks[Key] : BlockTablesMissing<Blocks[Key]['name']>;
93
+ };
94
+ export type AppDefinition<Group extends Tables, Custom extends StoreClasses, Classes extends Record<string, ModelClassLike>, Routes extends readonly RouteDefinition[], Blocks extends readonly AppBlock[] = readonly []> = {
81
95
  tables: Group;
96
+ blocks?: Blocks & CheckedBlocks<Group, Blocks>;
82
97
  stores?: Custom;
83
98
  models: {
84
99
  [Key in keyof Classes]: Key extends keyof BuiltInModels | 'app' ? StaticTypeError<`${Key & string} is a built-in model of @trypattern/sdk: its state, answers and commands come from Pattern. Name yours for what it holds.`> : PlainJsonModel<Classes[Key]>;
@@ -105,12 +120,12 @@ export type AppSnapshotShape<Models, Stores, Page> = {
105
120
  stores: Snapshots<Stores>;
106
121
  };
107
122
  type AppCommandOf<Classes extends Record<string, ModelClassLike>, Routes extends readonly RouteDefinition[]> = MessagesOf<Instances<BuiltInModels>> | MessagesOf<Instances<Classes>> | NavigateCommand<PathOf<Routes>> | PressKeyCommand;
108
- export type DefinedApp<Group extends Tables, Classes extends Record<string, ModelClassLike>, Routes extends readonly RouteDefinition[], Custom extends StoreClasses> = {
123
+ export type DefinedApp<Group extends Tables, Classes extends Record<string, ModelClassLike>, Routes extends readonly RouteDefinition[], Custom extends StoreClasses, Blocks extends readonly AppBlock[] = readonly []> = {
109
124
  new (router: DataRouter, dependencies: AppDependencies<AppApi<Group>>): RunningApp<AppCommandOf<Classes, Routes>, AppSnapshotShape<Instances<BuiltInModels & Classes>, StoresOf<Group, StoreInstances<Custom>>, PageOf<Routes>>> & {
110
125
  readonly models: Instances<BuiltInModels & Classes>;
111
126
  readonly stores: StoresOf<Group, StoreInstances<Custom>>;
112
127
  pressKey(key: string): Promise<void> | undefined;
113
128
  };
114
- readonly definition: AppDefinition<Group, Custom, Classes, Routes>;
129
+ readonly definition: AppDefinition<Group, Custom, Classes, Routes, Blocks>;
115
130
  };
116
- export declare function defineApp<Group extends Tables, Classes extends Record<string, ModelClassLike>, Routes extends readonly RouteDefinition[], Custom extends StoreClasses = Record<never, never>>(definition: AppDefinition<Group, Custom, Classes, Routes>): DefinedApp<Group, Classes, Routes, Custom>;
131
+ export declare function defineApp<Group extends Tables, Classes extends Record<string, ModelClassLike>, Routes extends readonly RouteDefinition[], Custom extends StoreClasses = Record<never, never>, const Blocks extends readonly AppBlock[] = readonly []>(definition: AppDefinition<Group, Custom, Classes, Routes, Blocks>): DefinedApp<Group, Classes, Routes, Custom, Blocks>;
package/dist/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { viewerOf } from "./access.js";
3
3
  import { AppStore } from "./appInfo.js";
4
+ import { blockWiringProblem } from "./blocks.js";
4
5
  import { SessionStore } from "./auth/sessionStore.js";
5
6
  import { realClock } from "./clock.js";
6
7
  import { ActionsStore } from "./actions.js";
@@ -54,7 +55,30 @@ const builtInModels = {
54
55
  signIn: SignIn
55
56
  };
56
57
  const appPathInput = z.custom((value) => typeof value === 'string' && isRoutedPath(value), { error: 'Not a page in src/routes.tsx' });
58
+ function withBlocks(definition, blocks) {
59
+ if (blocks.length === 0)
60
+ return definition;
61
+ const services = {
62
+ ...definition.services
63
+ };
64
+ const memory = { ...definition.memory };
65
+ for (const block of blocks) {
66
+ for (const group of Object.keys(block.services)) {
67
+ if (Object.hasOwn(services, group)) {
68
+ throw new Error(`The ${block.name} block serves the ${group} calls, and so does src/services/index.ts: give your own services another name.`);
69
+ }
70
+ services[group] = block.services[group] ?? {};
71
+ memory[group] = Reflect.get(block.memory, group);
72
+ }
73
+ }
74
+ return { ...definition, services, memory };
75
+ }
57
76
  export function defineApp(definition) {
77
+ const blocks = definition.blocks ?? [];
78
+ const wiring = blockWiringProblem(definition.tables, blocks, 'app');
79
+ if (wiring !== undefined)
80
+ throw new Error(wiring);
81
+ const defined = withBlocks(definition, blocks);
58
82
  const pages = new Set(pageIds(definition.routes));
59
83
  registerRoutes(definition.routes);
60
84
  if (Object.hasOwn(definition.models, 'app')) {
@@ -66,7 +90,7 @@ export function defineApp(definition) {
66
90
  }
67
91
  }
68
92
  return class App {
69
- static definition = definition;
93
+ static definition = defined;
70
94
  router;
71
95
  models;
72
96
  stores;
@@ -97,15 +121,20 @@ export function defineApp(definition) {
97
121
  key,
98
122
  new Store(cache.persisted(`stores.${key}`), api.services)
99
123
  ]));
124
+ const blockStores = Object.fromEntries(blocks.map((block) => [
125
+ block.name,
126
+ new block.Store(cache.persisted(`stores.${block.name}`), api.services)
127
+ ]));
100
128
  const toasts = new Toasts(cache.persisted('stores.toasts'), {});
101
129
  const clash = clashOf([
102
130
  ['session', 'app', 'actions', 'toasts', 'page'],
103
131
  Object.keys(data.tables),
104
132
  Object.keys(data.publicViews),
133
+ Object.keys(blockStores),
105
134
  Object.keys(custom)
106
135
  ]);
107
136
  if (clash !== undefined) {
108
- 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 and each store in src/stores/index.ts need their own name.`);
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.`);
109
138
  }
110
139
  const actions = new ActionsStore(this.#page, [...(definition.actions ?? []), ...builtInActions], [
111
140
  ...(definition.actionGroups ?? []),
@@ -119,6 +148,7 @@ export function defineApp(definition) {
119
148
  page: this.#page,
120
149
  ...data.tables,
121
150
  ...data.publicViews,
151
+ ...blockStores,
122
152
  ...custom
123
153
  };
124
154
  this.#session = session;
@@ -0,0 +1,18 @@
1
+ import type { Persisted } from './state.ts';
2
+ import type { Services } from './services.ts';
3
+ import type { Tables } from './table.ts';
4
+ export type BlockStoreClass = new (persisted: Persisted, services: never) => {
5
+ readonly state: object;
6
+ readonly subscribe: (listener: () => void) => () => void;
7
+ };
8
+ export type AppBlock = {
9
+ readonly name: string;
10
+ readonly tables: Tables;
11
+ readonly services: Services;
12
+ readonly memory: object;
13
+ readonly Store: BlockStoreClass;
14
+ };
15
+ export declare function blockWiringProblem(tables: Tables, blocks: readonly {
16
+ readonly name: string;
17
+ readonly tables: Tables;
18
+ }[], side: 'server' | 'app'): string | undefined;
package/dist/blocks.js ADDED
@@ -0,0 +1,36 @@
1
+ import { bookingTables } from "./bookings.js";
2
+ const KNOWN_BLOCKS = [
3
+ {
4
+ name: 'bookings',
5
+ tables: bookingTables,
6
+ tablesExport: 'bookingTables',
7
+ server: "bookings({ timezone: 'America/Toronto' })"
8
+ }
9
+ ];
10
+ function includesAll(tables, wanted) {
11
+ const present = new Set(Object.values(tables));
12
+ return Object.values(wanted).every((table) => present.has(table));
13
+ }
14
+ function includesAny(tables, wanted) {
15
+ const present = new Set(Object.values(tables));
16
+ return Object.values(wanted).some((table) => present.has(table));
17
+ }
18
+ export function blockWiringProblem(tables, blocks, side) {
19
+ for (const block of blocks) {
20
+ if (includesAll(tables, block.tables))
21
+ continue;
22
+ const known = KNOWN_BLOCKS.find((item) => item.name === block.name);
23
+ const name = known?.tablesExport ?? `the ${block.name} tables`;
24
+ return `The ${block.name} block keeps its rows in Pattern's own tables: add ...${name} from '@trypattern/sdk/schema' to tables in schema.ts, then run app migration add_${block.name}`;
25
+ }
26
+ for (const known of KNOWN_BLOCKS) {
27
+ if (!includesAny(tables, known.tables))
28
+ continue;
29
+ if (blocks.some((block) => block.name === known.name))
30
+ continue;
31
+ return side === 'server'
32
+ ? `tables has the ${known.name} block's tables, so the server needs the block: add blocks: [${known.server}] from '@trypattern/sdk/server' to createServer in server/worker.ts`
33
+ : `tables has the ${known.name} block's tables, so the app needs the block: add blocks: [${known.name}] from '@trypattern/sdk' to defineApp in src/App.ts`;
34
+ }
35
+ return undefined;
36
+ }