create-substrat 0.7.0 → 0.7.2

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/index.js CHANGED
@@ -35,10 +35,10 @@ const TEMPLATE = join(HERE, 'template');
35
35
  // The runtime packages release together off one version line (the changesets `fixed`
36
36
  // group), so one constant is right for all of them. Engines do NOT share a line —
37
37
  // each versions on its own, so one pin per engine, deliberately.
38
- const SUBSTRAT = '^0.83.0';
39
- const ENGINE_WORKORDER = '^0.7.3';
40
- const ENGINE_INVOICING = '^0.8.3';
41
- const BOUNDARY_LINT = '^0.0.8';
38
+ const SUBSTRAT = '^0.85.0';
39
+ const ENGINE_WORKORDER = '^0.8.1';
40
+ const ENGINE_INVOICING = '^0.9.1';
41
+ const BOUNDARY_LINT = '^0.1.1';
42
42
 
43
43
  const DOCS = 'https://substrat.net';
44
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -7,8 +7,8 @@ reshape the reference into it. Read the whole thing before starting — both the
7
7
  (Step 4) and the checkpoints (Step 7) are hard stops.
8
8
 
9
9
  **The target is a reviewed design, not running code.** Steps 1–2 learn the domain and map it
10
- onto what already exists; Step 3 writes a checked-in `DESIGN.md` in the user's own vocabulary;
11
- Step 4 is a **hard stop** where the user reads and approves it. Only then does Step 5 reshape
10
+ onto what already exists; Step 3 writes a checked-in `spec/concept.md` in the user's own
11
+ vocabulary; Step 4 is a **hard stop** where the user reads and approves it. Only then does Step 5 reshape
12
12
  the reference into their domain. The design gate (Step 4) is *upstream* of the two code
13
13
  checkpoints (Step 7) — a user with zero Substrat knowledge gets to say "yes, that's the app I
14
14
  want" before implementation, not after.
@@ -147,8 +147,9 @@ stop. Do not scaffold.
147
147
  ## Step 3 — Write the design document
148
148
 
149
149
  **This is the deliverable.** Everything before now was learning; this is where it lands
150
- somewhere the user can hold. Write a **checked-in `DESIGN.md`** in the project root, in the
151
- user's own vocabulary — no Substrat internals, no decision refs, no cross-references to
150
+ somewhere the user can hold. Write a **checked-in `spec/concept.md`** — beside
151
+ `spec/model.ts`, which is this same design one rung more concrete — in the user's own
152
+ vocabulary — no Substrat internals, no decision refs, no cross-references to
152
153
  platform docs. Someone who has never heard of Substrat must be able to read it and recognise
153
154
  their own business.
154
155
 
@@ -291,8 +292,8 @@ and event subjects all need one id, so naming such an entity in `parents`,
291
292
  compile error. It is still a full model member with migrations and a row type. A
292
293
  single-column key that is not called `id` stays fully pointable.
293
294
 
294
- Behaviour stays prose in `DESIGN.md`. Inventing a way to declare a state *transition* means
295
- the boundary slipped.
295
+ Behaviour stays prose in `spec/concept.md`. Inventing a way to declare a state *transition*
296
+ means the boundary slipped.
296
297
 
297
298
  Full reference: https://substrat.net/concepts/model
298
299
 
@@ -307,7 +308,7 @@ to make the build pass.
307
308
  The design is approved. The scaffold already contains a working vertical in `src/` + `test/` —
308
309
  the bike-repair shop. **Read it first** (it's your Callout: the real, green implementation of
309
310
  every pattern this step describes), then reshape it into the user's domain from the approved
310
- `DESIGN.md`:
311
+ `spec/concept.md`:
311
312
 
312
313
  - **Rename the vocabulary** — `shop_customers`/`shop_bikes` → the user's nouns, the `shop/*`
313
314
  operation names, the roles, the price-list shape. If the user's core noun maps onto a work
@@ -87,10 +87,15 @@ push` reads the JSON, not the TypeScript.
87
87
  ## The rules (non-negotiable)
88
88
 
89
89
  **Module code** = everything reachable from a `ModuleRegistration` (operations,
90
- consumers). Rules 1–4 are enforced mechanically by `boundary-lint`.
91
-
92
- 1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter, or
93
- `node:*` in module code.
90
+ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
91
+
92
+ 1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter,
93
+ `node:*`, or `cloudflare:workers` in module code. That last one is not a style rule:
94
+ it exports an ambient `env`, so a single import hands module code every binding and
95
+ secret your worker declares — including its own `SCOPE` Durable Object namespace,
96
+ which reaches *another scope's* data. `ctx.sql` is closed over one scope and cannot.
97
+ Capabilities arrive on `ctx`; `DurableObject` is imported in harness code
98
+ (`worker.ts`, `*-do.ts`), never here.
94
99
  2. **No `fetch` / network in module code.** It would hold the scope's transaction open on
95
100
  a third party. The sanctioned path is a **connector**: emit a fat event, register a
96
101
  handler that runs outside the transaction. An integration is never impossible because
@@ -102,16 +107,22 @@ consumers). Rules 1–4 are enforced mechanically by `boundary-lint`.
102
107
  the shortcut *works* and silently welds you to an engine's private schema forever. Need
103
108
  extra data on an engine entity? Add **your own side table keyed by the engine's id** —
104
109
  never a column upstream.
105
- 5. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))`
110
+ 5. **Time comes from `ctx.now()`.** Module code has no other clock — `new Date()` and
111
+ `Date.now()` are banned exactly like `node:*`. It is the same instant for the whole
112
+ operation, so your rows and the events announcing them agree about when. Store it as
113
+ ISO text, never an epoch integer. Because the host injects the clock, a scenario can
114
+ test elapsed time (`manualClock` from `@substrat-run/kernel`) instead of sleeping or
115
+ shrinking the window to zero — the workaround that proves nothing.
116
+ 6. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))`
106
117
  is the first line.
107
- 6. **Every mutation emits a fat event** — a consumer must never need a cross-module read.
108
- 7. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its
118
+ 7. **Every mutation emits a fat event** — a consumer must never need a cross-module read.
119
+ 8. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its
109
120
  line wrong — that's design feedback, not a coding problem.
110
- 8. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
121
+ 9. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
111
122
  (`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
112
- 9. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
113
- hand-roll a hash to dodge an import ban.
114
- 10. **Parse, don't trust.** Zod at every boundary — but import `z` from
123
+ 10. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
124
+ hand-roll a hash to dodge an import ban.
125
+ 11. **Parse, don't trust.** Zod at every boundary — but import `z` from
115
126
  `@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
116
127
  copies or majors; composing a contracts schema into one built from a separate `zod`
117
128
  fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
@@ -128,7 +139,7 @@ This is also what lets a portal permission-walk reach the owner.
128
139
 
129
140
  ```sh
130
141
  npm test # the scenario, including the denials
131
- npx @substrat-run/boundary-lint # the layer rules (1–4)
142
+ npx @substrat-run/boundary-lint # the layer rules (1–5)
132
143
  npm run typecheck
133
144
  ```
134
145
 
@@ -3,15 +3,18 @@ import {
3
3
  compareDecimal,
4
4
  moneyOf,
5
5
  mulMoney,
6
+ pageVisible,
6
7
  z,
7
8
  type EntityRef,
8
9
  type Money,
10
+ type Page,
9
11
  } from '@substrat-run/contracts';
10
12
  import {
11
13
  assertAllowed,
12
14
  ulid,
13
15
  type ModuleRegistration,
14
16
  type OperationHandler,
17
+ type PageParams,
15
18
  } from '@substrat-run/kernel';
16
19
  import {
17
20
  closeWorkOrder,
@@ -71,7 +74,7 @@ const createCustomerOp: OperationHandler<
71
74
  const id = ulid();
72
75
  ctx.sql.exec(
73
76
  `INSERT INTO shop_customers (id, number, name, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
74
- [id, input.number, input.name, input.phone ?? null, new Date().toISOString()],
77
+ [id, input.number, input.name, input.phone ?? null, ctx.now()],
75
78
  );
76
79
  return ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [id])[0]!;
77
80
  };
@@ -101,7 +104,7 @@ const registerBikeOp: OperationHandler<
101
104
  const id = ulid();
102
105
  ctx.sql.exec(
103
106
  `INSERT INTO shop_bikes (id, customer_id, label, frame_no, created_at) VALUES (?, ?, ?, ?, ?)`,
104
- [id, customer.id, input.label, input.frameNo ?? null, new Date().toISOString()],
107
+ [id, customer.id, input.label, input.frameNo ?? null, ctx.now()],
105
108
  );
106
109
  // Record the bike → customer edge the manifest declared, so the portal walk
107
110
  // (workorder → bike → customer) can resolve an entity-narrowed grant.
@@ -244,15 +247,25 @@ const closeRepairOp: OperationHandler<{ orderId: string }, WorkOrder> = async (c
244
247
  * PER-ENTITY check per repair. A portal customer holds an entity-narrowed grant
245
248
  * on their own customer record, so the walk workorder → bike → customer lets
246
249
  * them through for their own repairs and no one else's.
250
+ *
251
+ * Paged by OVER-FETCHING, which is what a permission-filtered walk needs: a page
252
+ * of 20 rows read from the table can leave 3 standing after the proof walk, so the
253
+ * fetch size and the page size are not the same number and cannot be made the same
254
+ * number. `pageVisible` does the over-fetch and advances the cursor by the last row
255
+ * EXAMINED — advancing by the last row RETURNED would re-examine every rejected row
256
+ * on the next request, and a page the walk rejects entirely would never advance at
257
+ * all. So a SHORT page does not end this walk; only a null `nextCursor` does.
247
258
  */
248
- const portalRepairsOp: OperationHandler<undefined, WorkOrder[]> = async (ctx) => {
249
- const visible: WorkOrder[] = [];
250
- for (const order of listOrders(ctx)) {
251
- const decision = await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id });
252
- if (decision.allowed) visible.push(order);
253
- }
254
- return visible;
255
- };
259
+ const portalRepairsOp: OperationHandler<PageParams | undefined, Page<WorkOrder>> = async (
260
+ ctx,
261
+ input,
262
+ ) =>
263
+ pageVisible(
264
+ (p) => listOrders(ctx, { ...input, ...p }),
265
+ input,
266
+ async (order) =>
267
+ (await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id })).allowed,
268
+ );
256
269
 
257
270
  const timelineInput = z.object({
258
271
  entityType: z.string().min(1),
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import Database from 'better-sqlite3';
5
5
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6
- import { addMoney, moneyOf, mulMoney } from '@substrat-run/contracts';
6
+ import { addMoney, moneyOf, mulMoney, type Page } from '@substrat-run/contracts';
7
7
  import type { ScopeStub } from '@substrat-run/kernel';
8
8
  import type { SqliteScopeHost } from '@substrat-run/adapter-sqlite';
9
9
  import type { WorkOrder, BillableLine } from '@substrat-run/engine-workorder';
@@ -105,7 +105,8 @@ describe('bike-shop scenario', () => {
105
105
  lisbeth.invoke('workorder/report-time', { orderId: repairId, hours: '1' }),
106
106
  ).rejects.toThrow(/permission denied/);
107
107
  // …but she CAN see her own repair through the portal walk.
108
- await expect(lisbeth.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toHaveLength(1);
108
+ const lisbethSees = await lisbeth.invoke<Page<WorkOrder>>('shop/portal-repairs');
109
+ expect(lisbethSees.entries).toHaveLength(1);
109
110
 
110
111
  // The cross-tenant attacker: claiming t1's scope under his OWN tenant fails
111
112
  // the (tenant, scope) pair check…
@@ -120,7 +121,8 @@ describe('bike-shop scenario', () => {
120
121
  await expect(rutger.invoke('invoicing/list')).rejects.toThrow(/permission denied/);
121
122
  // …the control: the per-entity portal walk resolves for him too, and returns
122
123
  // exactly nothing — an open door onto an empty room, not a denial.
123
- await expect(rutger.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toEqual([]);
124
+ const rutgerSees = await rutger.invoke<Page<WorkOrder>>('shop/portal-repairs');
125
+ expect(rutgerSees.entries).toEqual([]);
124
126
  });
125
127
 
126
128
  it('5. priced completion: the half-hour minimum bills, internal dropped, math exact', async () => {
@@ -153,9 +155,9 @@ describe('bike-shop scenario', () => {
153
155
  });
154
156
 
155
157
  it('6. star topology: the invoicing engine consumed workorder.completed', async () => {
156
- const underlag = await greta.invoke<{ id: string; status: string; total: string }[]>(
157
- 'invoicing/list',
158
- );
158
+ const { entries: underlag } = await greta.invoke<
159
+ Page<{ id: string; status: string; total: string }>
160
+ >('invoicing/list');
159
161
  expect(underlag).toHaveLength(1);
160
162
  expect(underlag[0]!.status).toBe('open');
161
163
  expect(underlag[0]!.total).toBe('336.5');
@@ -178,9 +180,10 @@ describe('bike-shop scenario', () => {
178
180
  const lisbeth = await host.getScope(w.lisbeth, w.t1, w.s1);
179
181
  const otto = await host.getScope(w.otto, w.t1, w.s1);
180
182
 
181
- const hers = await lisbeth.invoke<WorkOrder[]>('shop/portal-repairs');
182
- expect(hers.map((o) => o.id)).toEqual([repairId]);
183
- await expect(otto.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toEqual([]);
183
+ const hers = await lisbeth.invoke<Page<WorkOrder>>('shop/portal-repairs');
184
+ expect(hers.entries.map((o) => o.id)).toEqual([repairId]);
185
+ const ottos = await otto.invoke<Page<WorkOrder>>('shop/portal-repairs');
186
+ expect(ottos.entries).toEqual([]);
184
187
 
185
188
  // Lisbeth reads her repair's timeline via the same entity walk…
186
189
  await expect(
@@ -194,7 +197,9 @@ describe('bike-shop scenario', () => {
194
197
  });
195
198
 
196
199
  it('8. export makes the underlag immutable; the next completion opens a new one', async () => {
197
- const [underlag] = await greta.invoke<{ id: string }[]>('invoicing/list');
200
+ const {
201
+ entries: [underlag],
202
+ } = await greta.invoke<Page<{ id: string }>>('invoicing/list');
198
203
  await greta.invoke('invoicing/export', { underlagId: underlag!.id });
199
204
  await expect(greta.invoke('invoicing/export', { underlagId: underlag!.id })).rejects.toThrow(
200
205
  /immutable/,
@@ -214,7 +219,9 @@ describe('bike-shop scenario', () => {
214
219
  });
215
220
  await greta.invoke('shop/complete-repair', { orderId: repair2.id });
216
221
 
217
- const all = await greta.invoke<{ status: string; total: string }[]>('invoicing/list');
222
+ const { entries: all } = await greta.invoke<Page<{ status: string; total: string }>>(
223
+ 'invoicing/list',
224
+ );
218
225
  expect(all).toHaveLength(2);
219
226
  expect(all.filter((u) => u.status === 'open')).toHaveLength(1);
220
227
  expect(all.filter((u) => u.status === 'exported')).toHaveLength(1);