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 +4 -4
- package/package.json +1 -1
- package/template/.substrat/playbook.md +8 -7
- package/template/AGENTS.md +23 -12
- package/template/src/module.ts +23 -10
- package/template/test/scenario.test.ts +18 -11
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.
|
|
39
|
-
const ENGINE_WORKORDER = '^0.
|
|
40
|
-
const ENGINE_INVOICING = '^0.
|
|
41
|
-
const BOUNDARY_LINT = '^0.
|
|
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
|
@@ -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 `
|
|
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 `
|
|
151
|
-
|
|
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 `
|
|
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
|
-
`
|
|
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
|
package/template/AGENTS.md
CHANGED
|
@@ -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–
|
|
91
|
-
|
|
92
|
-
1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter,
|
|
93
|
-
`node
|
|
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. **
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
121
|
+
9. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
|
|
111
122
|
(`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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–
|
|
142
|
+
npx @substrat-run/boundary-lint # the layer rules (1–5)
|
|
132
143
|
npm run typecheck
|
|
133
144
|
```
|
|
134
145
|
|
package/template/src/module.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
|
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<
|
|
157
|
-
|
|
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
|
|
182
|
-
expect(hers.map((o) => o.id)).toEqual([repairId]);
|
|
183
|
-
await
|
|
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
|
|
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 }
|
|
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);
|