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 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.142.0';
40
- const ENGINE_WORKORDER = '^0.12.26';
41
- const ENGINE_INVOICING = '^0.11.26';
42
- const BOUNDARY_LINT = '^0.6.2';
43
- const DEV_ISSUER = '^0.2.24';
44
- const ATTACHMENT_EXTRACTORS = '^0.2.3';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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 passes
380
- `operationInputs: operationInputsOf(<vertical>Operations)` beside its `operations`, and
381
- the host parses every invocation against the declared schema before the guards and the
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
@@ -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
- } satisfies OperationImpl<typeof bikeShopOperations, OperationContext>;
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
- Four things are now compile errors at the exact method: a handler whose input
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, and one implemented and not declared. The
159
- same object feeds `operationInputs: operationInputsOf(bikeShopOperations)`, so the
160
- schemas the host parses with are the ones the declaration states — they cannot drift,
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 passes
243
- `operationInputs: operationInputsOf(ops)` beside its `operations`, and every invocation
244
- is parsed against the declared schema before the guards and the handler, on every path
245
- in (HTTP, test, seed, schedule). Handlers do not hand-parse; a declared input nobody
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
@@ -1,4 +1,4 @@
1
- import { defineEntities, emitModel, z } from '@substrat-run/contracts';
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 });
@@ -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 `satisfies OperationImpl<…>`, so a handler that disagrees
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 new Error(`customer not found: ${input.customerId}`);
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 new Error(`bike not found: ${input.bikeId}`);
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`. `satisfies` is the drift
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 declaredOperations = {
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
- } satisfies OperationImpl<typeof bikeShopOperations, OperationContext>;
313
+ });
314
314
 
315
315
  export const bikeShopModule: ModuleRegistration = {
316
316
  manifest: bikeShopManifest,
317
317
  migrations: bikeShopMigrations,
318
- // The host parses every invocation against the DECLARED input schemas before
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 `satisfies OperationImpl<…>`, so four
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
- // `operationInputsOf(bikeShopOperations)` hands the host the same schemas, so
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, bikeShopModel } from '../src/entities.js';
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
  });