create-substrat 0.11.2 → 0.11.4
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 +6 -6
- package/package.json +1 -1
- package/template/.substrat/playbook.md +3 -3
- package/template/AGENTS.md +34 -11
- package/template/src/entities.ts +1 -11
- package/template/src/model.ts +16 -0
- package/template/src/module.ts +11 -22
- package/template/src/operations.ts +3 -4
- package/template/test/entities.test.ts +2 -1
- package/template/test/scenario.test.ts +31 -0
package/index.js
CHANGED
|
@@ -36,12 +36,12 @@ const TEMPLATE = join(HERE, 'template');
|
|
|
36
36
|
// The runtime packages release together off one version line (the changesets `fixed`
|
|
37
37
|
// group), so one constant is right for all of them. Engines do NOT share a line —
|
|
38
38
|
// each versions on its own, so one pin per engine, deliberately.
|
|
39
|
-
const SUBSTRAT = '^0.
|
|
40
|
-
const ENGINE_WORKORDER = '^0.12.
|
|
41
|
-
const ENGINE_INVOICING = '^0.11.
|
|
42
|
-
const BOUNDARY_LINT = '^0.
|
|
43
|
-
const DEV_ISSUER = '^0.2.
|
|
44
|
-
const ATTACHMENT_EXTRACTORS = '^0.2.
|
|
39
|
+
const SUBSTRAT = '^0.144.0';
|
|
40
|
+
const ENGINE_WORKORDER = '^0.12.28';
|
|
41
|
+
const ENGINE_INVOICING = '^0.11.28';
|
|
42
|
+
const BOUNDARY_LINT = '^0.7.0';
|
|
43
|
+
const DEV_ISSUER = '^0.2.26';
|
|
44
|
+
const ATTACHMENT_EXTRACTORS = '^0.2.5';
|
|
45
45
|
|
|
46
46
|
const DOCS = 'https://substrat.net';
|
|
47
47
|
|
package/package.json
CHANGED
|
@@ -376,9 +376,9 @@ operations + the `ModuleRegistration`. Keep the split — the linter and tests e
|
|
|
376
376
|
timestamps, money/decimals as TEXT. **Append-only forever after first ship.**
|
|
377
377
|
- Operations: first line is always `assertAllowed(await ctx.check(PERM))`.
|
|
378
378
|
`ctx.link(child, parent)` when creating related entities.
|
|
379
|
-
- **Handlers do not hand-parse their input.** The module
|
|
380
|
-
|
|
381
|
-
the host parses every invocation against
|
|
379
|
+
- **Handlers do not hand-parse their input.** The module binds its handlers with
|
|
380
|
+
`...operationsFor(<vertical>Operations)({ … })`, which hands the host the declared schemas
|
|
381
|
+
with them, and the host parses every invocation against them before the guards and the
|
|
382
382
|
handler run — on every path in (HTTP, test, seed, schedule). The reference module already
|
|
383
383
|
does this; keep it. An inline `z.object(…).parse(input)` at the top of a handler is a
|
|
384
384
|
second description of a schema the model already declares, and it only covers the paths
|
package/template/AGENTS.md
CHANGED
|
@@ -150,15 +150,21 @@ export const bikeShopOperations = defineOperations(bikeShopEntities, SHOP_PERMIS
|
|
|
150
150
|
Then `src/module.ts` holds only the **bodies**, bound to that declaration:
|
|
151
151
|
|
|
152
152
|
```ts
|
|
153
|
-
|
|
153
|
+
const bound = operationsFor(bikeShopOperations)({
|
|
154
|
+
'shop/create-customer': createCustomerOp,
|
|
155
|
+
…
|
|
156
|
+
});
|
|
157
|
+
export const bikeShopModule: ModuleRegistration = { manifest, migrations, ...bound };
|
|
154
158
|
```
|
|
155
159
|
|
|
156
|
-
|
|
160
|
+
Five things are now compile errors at the exact method: a handler whose input
|
|
157
161
|
disagrees with the declared `input`, one whose return disagrees with `output`, an
|
|
158
|
-
operation declared and not implemented,
|
|
159
|
-
|
|
160
|
-
schemas
|
|
161
|
-
because there is only one of them.
|
|
162
|
+
operation declared and not implemented, one implemented and not declared, and one
|
|
163
|
+
whose type was thrown away with `as never` or `as any`. The same call hands the host
|
|
164
|
+
the schemas it parses with (`operationInputsOf(bikeShopOperations)`), so they are the
|
|
165
|
+
ones the declaration states — they cannot drift, because there is only one of them.
|
|
166
|
+
A registration takes no other kind of map: a hand-written `operations: { … }` does not
|
|
167
|
+
compile, and `boundary-lint` refuses a cast of the whole map.
|
|
162
168
|
|
|
163
169
|
The permission list works the same way. `SHOP_PERMISSIONS`, the array handed to
|
|
164
170
|
`defineOperations`, is also the `keys` that `src/provision.ts` hands
|
|
@@ -190,6 +196,23 @@ matter what the operation supports, forget the second and it answers with an
|
|
|
190
196
|
envelope where it used to answer with an array. That is the case for not
|
|
191
197
|
hand-mounting anything an operation can declare.
|
|
192
198
|
|
|
199
|
+
**An operation the model describes completely says who writes its handler.** These
|
|
200
|
+
shapes are derivable: a `GET` of one row by its id, a `GET` that is a `paged.over` page of the
|
|
201
|
+
entity's own rows behind declared filters, a `PATCH` field bag with `concurrency`, a
|
|
202
|
+
`DELETE` of a row nothing declares as parent, a `POST` that inserts a row every column of
|
|
203
|
+
which comes from the input, the parent its check narrows to, a minted id or the entity's
|
|
204
|
+
declared `stamps`, and, on an entity that declares `archive` or `trash`, its four moves and
|
|
205
|
+
its bin. Declare `derive: 'get'` (or `'list'`, `'update'`, `'delete'`, `'create'`,
|
|
206
|
+
`'archive'`, `'unarchive'`, `'trash'`, `'restore'`, `'trashed'`) and write no handler:
|
|
207
|
+
`operationsFor` leaves it out of the map it requires and supplies the platform's handler,
|
|
208
|
+
which checks the declared key first, reads and writes only declared columns and emits the
|
|
209
|
+
declared event (the bin is checked row by row instead, and a move's event is the kernel's).
|
|
210
|
+
Keep a handler
|
|
211
|
+
only with `authored: '<what the derived one would get wrong here>'`. `defineOperations`
|
|
212
|
+
refuses at module load a derivable operation that declares neither, a `derive` whose
|
|
213
|
+
declaration is not that shape, and an `authored` on an operation nothing could derive.
|
|
214
|
+
Each refusal names its remedy.
|
|
215
|
+
|
|
193
216
|
## The rules (non-negotiable)
|
|
194
217
|
|
|
195
218
|
**Module code** = everything reachable from a `ModuleRegistration` (operations,
|
|
@@ -239,11 +262,11 @@ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
|
|
|
239
262
|
(`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
|
|
240
263
|
10. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
|
|
241
264
|
hand-roll a hash to dodge an import ban.
|
|
242
|
-
11. **Parse, don't trust** — and the **host** is what parses. A module
|
|
243
|
-
`
|
|
244
|
-
is parsed against
|
|
245
|
-
in (HTTP, test, seed, schedule). Handlers do not hand-parse; a declared
|
|
246
|
-
parses stops being possible rather than merely discouraged. Import `z` from
|
|
265
|
+
11. **Parse, don't trust** — and the **host** is what parses. A module binds its
|
|
266
|
+
handlers with `operationsFor(ops)(…)`, which hands the host the declared schemas with
|
|
267
|
+
them, and every invocation is parsed against them before the guards and the handler,
|
|
268
|
+
on every path in (HTTP, test, seed, schedule). Handlers do not hand-parse; a declared
|
|
269
|
+
input nobody parses stops being possible rather than merely discouraged. Import `z` from
|
|
247
270
|
`@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
|
|
248
271
|
copies or majors; composing a contracts schema into one built from a separate `zod`
|
|
249
272
|
fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
|
package/template/src/entities.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineEntities,
|
|
1
|
+
import { defineEntities, z } from '@substrat-run/contracts';
|
|
2
2
|
|
|
3
3
|
// ============================================================================
|
|
4
4
|
// The bike shop's ENTITY REGISTRY — what this vertical declares exists.
|
|
@@ -71,13 +71,3 @@ export const bikeShopEntities = defineEntities({
|
|
|
71
71
|
},
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
* The artifact of record, emitted from the declaration above.
|
|
76
|
-
*
|
|
77
|
-
* A scaffolded project is not in this repo's `demos/`, so nothing here re-emits
|
|
78
|
-
* a `model.json` for it — `pnpm lint:model` walks `demos/` and `engines/`. What
|
|
79
|
-
* this export is for in a scaffold is the same thing it is for in the reference
|
|
80
|
-
* verticals: one object downstream reads, so the authoring notation stays
|
|
81
|
-
* swappable.
|
|
82
|
-
*/
|
|
83
|
-
export const bikeShopModel = emitModel(bikeShopEntities);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { emitModel } from '@substrat-run/contracts';
|
|
2
|
+
import { bikeShopEntities } from './entities.js';
|
|
3
|
+
import { bikeShopOperations } from './operations.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The artifact of record, emitted from the declaration.
|
|
7
|
+
*
|
|
8
|
+
* A file of its own, because it renders who writes each handler (`derive` or `authored`), so it
|
|
9
|
+
* reads the operations, and the operations import the entities. Nothing imports it back.
|
|
10
|
+
*
|
|
11
|
+
* A scaffolded project is not in this repo's `demos/`, so nothing here re-emits a `model.json`
|
|
12
|
+
* for it — `pnpm lint:model` walks `demos/` and `engines/`. What this export is for in a
|
|
13
|
+
* scaffold is the same thing it is for in the reference verticals: one object downstream reads, so
|
|
14
|
+
* the authoring notation stays swappable.
|
|
15
|
+
*/
|
|
16
|
+
export const bikeShopModel = emitModel(bikeShopEntities, { operations: bikeShopOperations });
|
package/template/src/module.ts
CHANGED
|
@@ -4,21 +4,20 @@ import {
|
|
|
4
4
|
listLimitOf,
|
|
5
5
|
moneyOf,
|
|
6
6
|
mulMoney,
|
|
7
|
-
operationInputsOf,
|
|
8
7
|
pageOf,
|
|
9
8
|
pageVisible,
|
|
9
|
+
substratError,
|
|
10
10
|
z,
|
|
11
11
|
type HandlerInput,
|
|
12
12
|
type HandlerOutput,
|
|
13
|
-
type OperationImpl,
|
|
14
13
|
} from '@substrat-run/contracts';
|
|
15
14
|
import {
|
|
16
15
|
assertAllowed,
|
|
17
16
|
readTimeline,
|
|
18
17
|
ulid,
|
|
19
18
|
type ModuleRegistration,
|
|
20
|
-
type OperationContext,
|
|
21
19
|
type OperationHandler,
|
|
20
|
+
operationsFor,
|
|
22
21
|
} from '@substrat-run/kernel';
|
|
23
22
|
import {
|
|
24
23
|
closeWorkOrder,
|
|
@@ -46,7 +45,7 @@ import { bikeShopMigrations } from './migrations.js';
|
|
|
46
45
|
//
|
|
47
46
|
// WHAT EACH OPERATION IS is declared in `src/operations.ts`, against the
|
|
48
47
|
// entities in `src/entities.ts` — this file holds only the bodies. The binding
|
|
49
|
-
// at the bottom is `
|
|
48
|
+
// at the bottom is `operationsFor(bikeShopOperations)`, so a handler that disagrees
|
|
50
49
|
// with its declaration is a compile error at the exact method.
|
|
51
50
|
// ============================================================================
|
|
52
51
|
|
|
@@ -111,7 +110,7 @@ const registerBikeOp: Op<'shop/register-bike'> = async (ctx, input) => {
|
|
|
111
110
|
const customer = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [
|
|
112
111
|
input.customerId,
|
|
113
112
|
])[0];
|
|
114
|
-
if (!customer) throw
|
|
113
|
+
if (!customer) throw substratError('not_found', `customer not found: ${input.customerId}`);
|
|
115
114
|
const id = ulid();
|
|
116
115
|
ctx.sql.exec(
|
|
117
116
|
`INSERT INTO shop_bikes (id, customer_id, label, frame_no, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -169,7 +168,7 @@ const priceListOp: Op<'shop/price-list'> = async (ctx, input) => {
|
|
|
169
168
|
const createRepairOp: Op<'shop/create-repair'> = async (ctx, input) => {
|
|
170
169
|
assertAllowed(await ctx.check(WO.create));
|
|
171
170
|
const bike = ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [input.bikeId])[0];
|
|
172
|
-
if (!bike) throw
|
|
171
|
+
if (!bike) throw substratError('not_found', `bike not found: ${input.bikeId}`);
|
|
173
172
|
return createWorkOrder(ctx, {
|
|
174
173
|
facility: { entityType: 'bike', entityId: bike.id },
|
|
175
174
|
customer: { entityType: 'customer', entityId: bike.customer_id },
|
|
@@ -294,12 +293,13 @@ const timelineOp: Op<'shop/timeline'> = async (ctx, input) => {
|
|
|
294
293
|
};
|
|
295
294
|
|
|
296
295
|
/**
|
|
297
|
-
* The handlers, bound to `bikeShopOperations`. `
|
|
296
|
+
* The handlers, bound to `bikeShopOperations`. `operationsFor` is the drift
|
|
298
297
|
* detector: change a declared input or return and tsc names the method whose
|
|
299
298
|
* handler no longer agrees. An operation declared but not implemented, or
|
|
300
|
-
* implemented but not declared, is an error here too
|
|
299
|
+
* implemented but not declared, is an error here too — and the schemas the
|
|
300
|
+
* host parses every invocation with come from the same declaration.
|
|
301
301
|
*/
|
|
302
|
-
const
|
|
302
|
+
const bound = operationsFor(bikeShopOperations)({
|
|
303
303
|
'shop/create-customer': createCustomerOp,
|
|
304
304
|
'shop/list-customers': listCustomersOp,
|
|
305
305
|
'shop/register-bike': registerBikeOp,
|
|
@@ -310,21 +310,10 @@ const declaredOperations = {
|
|
|
310
310
|
'shop/close-repair': closeRepairOp,
|
|
311
311
|
'shop/portal-repairs': portalRepairsOp,
|
|
312
312
|
'shop/timeline': timelineOp,
|
|
313
|
-
}
|
|
313
|
+
});
|
|
314
314
|
|
|
315
315
|
export const bikeShopModule: ModuleRegistration = {
|
|
316
316
|
manifest: bikeShopManifest,
|
|
317
317
|
migrations: bikeShopMigrations,
|
|
318
|
-
|
|
319
|
-
// the guards, the permission check and the handler — so "parse, don't trust"
|
|
320
|
-
// holds on every path in (HTTP, test, seed, schedule) rather than in the
|
|
321
|
-
// handlers that remembered to do it themselves.
|
|
322
|
-
operationInputs: operationInputsOf(bikeShopOperations),
|
|
323
|
-
operations: {
|
|
324
|
-
// All ten bound to the declaration: input and return are checked against
|
|
325
|
-
// `bikeShopOperations` at the exact method. The `as never` casts this map
|
|
326
|
-
// used to carry were never necessary — `OperationHandler<never, unknown>`
|
|
327
|
-
// accepts any handler by contravariance — they simply threw the types away.
|
|
328
|
-
...(declaredOperations as Record<string, OperationHandler<never, unknown>>),
|
|
329
|
-
},
|
|
318
|
+
...bound,
|
|
330
319
|
};
|
|
@@ -14,12 +14,11 @@ import { bikeShopEntities } from './entities.js';
|
|
|
14
14
|
// HTTP API.
|
|
15
15
|
//
|
|
16
16
|
// This is one declaration, not documentation of another one. `src/module.ts`
|
|
17
|
-
// binds its handlers to this object with `
|
|
18
|
-
// things become compile errors at the exact method: a handler whose input
|
|
17
|
+
// binds its handlers to this object with `operationsFor(bikeShopOperations)`, so
|
|
18
|
+
// four things become compile errors at the exact method: a handler whose input
|
|
19
19
|
// disagrees with `input`, one whose return disagrees with `output`, an
|
|
20
20
|
// operation declared and not implemented, and one implemented and not declared.
|
|
21
|
-
//
|
|
22
|
-
// every invocation is parsed before the guards and the handler — and
|
|
21
|
+
// The same call hands the host the declared schemas, so every invocation is parsed before the guards and the handler — and
|
|
23
22
|
// `mountOperations` (src/routes.ts) derives the route table from the `http`
|
|
24
23
|
// each operation declares, so a `{var}` in a path that names no input field is
|
|
25
24
|
// a compile error too, and there is no second list of routes to drift.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { bikeShopEntities
|
|
2
|
+
import { bikeShopEntities } from '../src/entities.js';
|
|
3
|
+
import { bikeShopModel } from '../src/model.js';
|
|
3
4
|
import { bikeShopMigrations } from '../src/migrations.js';
|
|
4
5
|
import { bikeShopManifest } from '../src/manifest.js';
|
|
5
6
|
|
|
@@ -6,6 +6,7 @@ import { Hono } from 'hono';
|
|
|
6
6
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
7
7
|
import {
|
|
8
8
|
addMoney,
|
|
9
|
+
errorCodeOf,
|
|
9
10
|
moneyOf,
|
|
10
11
|
mulMoney,
|
|
11
12
|
type Page,
|
|
@@ -435,4 +436,34 @@ describe('bike-shop scenario', () => {
|
|
|
435
436
|
expect((await timeline.json()) as unknown[]).toHaveLength(1);
|
|
436
437
|
expect(nextOf(timeline)).toMatch(/^\/api\/repairs\/.+\/timeline\?limit=1&cursor=.+$/);
|
|
437
438
|
});
|
|
439
|
+
|
|
440
|
+
// #113: a missing record is a refusal that SAYS it is `not_found`, rather than a sentence
|
|
441
|
+
// the host has to recognise. Its status follows from the code, so it survives the host
|
|
442
|
+
// dropping the wording it still reads untyped throws by — and so does the sentence.
|
|
443
|
+
it('13. an unknown customer or bike is a not_found, on the operation and over HTTP', async () => {
|
|
444
|
+
const unknown = 'nonexistent';
|
|
445
|
+
const refusalOf = (p: Promise<unknown>) => p.then(() => undefined, (e: unknown) => e);
|
|
446
|
+
|
|
447
|
+
const noCustomer = await refusalOf(
|
|
448
|
+
greta.invoke('shop/register-bike', { customerId: unknown, label: 'x' }),
|
|
449
|
+
);
|
|
450
|
+
expect(errorCodeOf(noCustomer)).toBe('not_found');
|
|
451
|
+
expect((noCustomer as Error).message).toBe(`customer not found: ${unknown}`);
|
|
452
|
+
|
|
453
|
+
const noBike = await refusalOf(
|
|
454
|
+
greta.invoke('shop/create-repair', { bikeId: unknown, kind: 'puncture', title: 'x' }),
|
|
455
|
+
);
|
|
456
|
+
expect(errorCodeOf(noBike)).toBe('not_found');
|
|
457
|
+
expect((noBike as Error).message).toBe(`bike not found: ${unknown}`);
|
|
458
|
+
|
|
459
|
+
const app = new Hono();
|
|
460
|
+
mountApi(app, async () => greta);
|
|
461
|
+
const res = await app.request('/api/repairs', {
|
|
462
|
+
method: 'POST',
|
|
463
|
+
headers: { 'content-type': 'application/json' },
|
|
464
|
+
body: JSON.stringify({ bikeId: unknown, kind: 'puncture', title: 'x' }),
|
|
465
|
+
});
|
|
466
|
+
expect(res.status).toBe(404);
|
|
467
|
+
expect(await res.json()).toMatchObject({ code: 'not_found', detail: `bike not found: ${unknown}` });
|
|
468
|
+
});
|
|
438
469
|
});
|