create-substrat 0.8.4 → 0.8.6

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,11 +36,11 @@ 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.95.0';
40
- const ENGINE_WORKORDER = '^0.10.3';
41
- const ENGINE_INVOICING = '^0.9.11';
42
- const BOUNDARY_LINT = '^0.3.0';
43
- const DEV_ISSUER = '^0.1.9';
39
+ const SUBSTRAT = '^0.98.0';
40
+ const ENGINE_WORKORDER = '^0.10.6';
41
+ const ENGINE_INVOICING = '^0.9.14';
42
+ const BOUNDARY_LINT = '^0.4.0';
43
+ const DEV_ISSUER = '^0.1.13';
44
44
 
45
45
  const DOCS = 'https://substrat.net';
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -104,6 +104,15 @@ Imported directly; their in-scope functions run in **your** transaction. Read ea
104
104
  - **`engine-invites`** — how a person joins an org they are not in. Identifiers stored
105
105
  hashed and never returned; an invitation confers nothing until accepted. Reach for it
106
106
  before hand-rolling any invite flow.
107
+ - **`engine-absence`** — leave and absence: leave types, requests that are decided rather
108
+ than simply written, and the balance they draw down (`requestAbsence`, `decideAbsence`,
109
+ `balanceAsOf`, `availability`). Which days count and which year a balance belongs to is
110
+ calendar policy, and calendar policy is yours.
111
+ - **`engine-metering`** — metered usage: meters, usage recorded against them, and periods
112
+ closed over them (`configureMeter`, `recordUsage`, `closePeriod`, `usageTotal`). By call
113
+ rather than by event on purpose — you record usage inside the same transaction as the
114
+ work that produced it, so the ledger row and the work commit or roll back together.
115
+ Reach for it before adding a `usage` table and a monthly `SUM` of your own.
107
116
 
108
117
  ### Tier 2 — engines you feed by event
109
118
 
@@ -363,11 +372,44 @@ operations + the `ModuleRegistration`. Keep the split — the linter and tests e
363
372
  the customer.
364
373
  - Migrations: `SqlMigration[]`, tables prefixed `<vertical>_`, TEXT ids, ISO-8601 TEXT
365
374
  timestamps, money/decimals as TEXT. **Append-only forever after first ship.**
366
- - Operations: first line is always `assertAllowed(await ctx.check(PERM))`. Parse inputs
367
- with Zod. `ctx.link(child, parent)` when creating related entities.
375
+ - Operations: first line is always `assertAllowed(await ctx.check(PERM))`.
376
+ `ctx.link(child, parent)` when creating related entities.
377
+ - **Handlers do not hand-parse their input.** The module passes
378
+ `operationInputs: operationInputsOf(<vertical>Operations)` beside its `operations`, and
379
+ the host parses every invocation against the declared schema before the guards and the
380
+ handler run — on every path in (HTTP, test, seed, schedule). The reference module already
381
+ does this; keep it. An inline `z.object(…).parse(input)` at the top of a handler is a
382
+ second description of a schema the model already declares, and it only covers the paths
383
+ that happen to reach that handler.
384
+ - **Time comes from `ctx.now()`.** Module code has no other clock; `new Date()` and
385
+ `Date.now()` are banned exactly like `node:*`. It is the same instant for the whole
386
+ invocation, so your rows and the events announcing them agree about when.
368
387
  - **The pricing moment is the pattern to copy**: read the engine's reported lines with
369
388
  `getReportedLines(ctx, orderId)` → apply the vertical's price list → call the engine's
370
389
  `completeWorkOrder`. One transaction, invariants intact.
390
+ - **Swallowing an engine error requires `ctx.atomic`.** An engine call composed inside your
391
+ transaction has no boundary of its own, so a `catch` that handles the failure and carries
392
+ on leaves you holding its partial writes — the rows its invariants were protecting — and
393
+ then commits them. Wrap it instead:
394
+
395
+ ```ts
396
+ try {
397
+ await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable }));
398
+ } catch {
399
+ // the engine's rows, events, links and grants are all gone; your own writes
400
+ // survive, and the operation still commits once
401
+ }
402
+ ```
403
+
404
+ A succeeded `ctx.atomic` is still provisional: if the operation later throws, its writes
405
+ go too. Sub-transactions nest but must not interleave — starting two concurrently throws.
406
+ The line is whether the failure still reaches the caller. A catch that **swallows** an
407
+ engine error — one that does not rethrow — is what needs the boundary, and outside
408
+ `ctx.atomic` `boundary-lint` rejects it with no escape hatch. A catch that always
409
+ rethrows (`catch (e) { log(e); throw e }`) needs nothing, and neither does
410
+ `try`/`finally` with no `catch`: the operation still fails and the whole transaction
411
+ rolls back, which is already the outcome the rule protects. Reach for `ctx.atomic` when
412
+ you intend to *continue* past the failure.
371
413
  - Portal listing: iterate and `ctx.check(perm, entityRef)` **per entity** — a proof walk,
372
414
  not UI filtering.
373
415
  - **An entity's history is `readTimeline(ctx, entity, input)` from `@substrat-run/kernel`** —
@@ -136,12 +136,46 @@ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
136
136
  (`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
137
137
  10. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
138
138
  hand-roll a hash to dodge an import ban.
139
- 11. **Parse, don't trust.** Zod at every boundary — but import `z` from
139
+ 11. **Parse, don't trust** — and the **host** is what parses. A module passes
140
+ `operationInputs: operationInputsOf(ops)` beside its `operations`, and every invocation
141
+ is parsed against the declared schema before the guards and the handler, on every path
142
+ in (HTTP, test, seed, schedule). Handlers do not hand-parse; a declared input nobody
143
+ parses stops being possible rather than merely discouraged. Import `z` from
140
144
  `@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
141
145
  copies or majors; composing a contracts schema into one built from a separate `zod`
142
146
  fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
143
147
  cause.
144
148
 
149
+ ## Swallowing an engine error requires `ctx.atomic`
150
+
151
+ An engine call composed inside your transaction has no boundary of its own. A `catch` that
152
+ handles the failure and carries on therefore leaves you holding the engine's partial
153
+ writes — the rows its invariants were protecting — and then commits them. Give the call a
154
+ boundary instead:
155
+
156
+ ```ts
157
+ try {
158
+ await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable }));
159
+ } catch {
160
+ // the engine's rows, events, links and grants are all gone; your own writes
161
+ // survive, and the operation still commits once
162
+ }
163
+ ```
164
+
165
+ A succeeded `ctx.atomic` is still provisional: if the operation later throws, its writes go
166
+ too. Sub-transactions nest but must not interleave — starting two concurrently throws.
167
+
168
+ The line is whether the failure still reaches the caller. A catch that **swallows** an
169
+ engine error — one that does not rethrow — is what needs the boundary, and outside
170
+ `ctx.atomic` `boundary-lint` rejects it with **no** escape hatch. A catch that always
171
+ rethrows (`catch (e) { log(e); throw e }`) needs nothing, and neither does `try`/`finally`
172
+ with no `catch`: the operation still fails and the whole transaction rolls back, which is
173
+ already the outcome the rule protects. `ctx.atomic` is what you reach for when you intend
174
+ to *continue* past the failure.
175
+
176
+ "Always rethrows" is read literally — the catch's last statement is the `throw`. A `throw`
177
+ buried in an `if` block is not that, because the catch runs on past it.
178
+
145
179
  ## Declare every link edge
146
180
 
147
181
  `entityRelations` in the manifest must declare every edge you traverse — both your own
@@ -189,6 +223,21 @@ vertical done, boot the server and drive the real flow over HTTP as two personas
189
223
  should succeed and one who should be denied — and confirm the denial arrives as a denial
190
224
  (not a generic error).
191
225
 
226
+ ## When it breaks — symptom → fix
227
+
228
+ Six failures that have each cost someone a day. What makes them expensive is that none of
229
+ them names its own cause: the symptom points somewhere other than the fix, so an agent
230
+ debugging from first principles walks away from the answer.
231
+
232
+ | Symptom | Fix |
233
+ |---|---|
234
+ | `expected a Zod schema` at runtime, pointing nowhere useful | Two copies of Zod. Never add `zod` to `package.json` — import `z` from `@substrat-run/contracts`, so the schema you build and the one the host validates with are the same class. |
235
+ | Killing the dev server kills unrelated ones too | `pkill -f 'tsx src/server.ts'` matches every Substrat project running on the machine, not just yours. Kill by port instead: `kill $(lsof -ti :<port>)`. |
236
+ | Green locally, red in CI, with nothing in the diff that explains it | A warm build output hides it. Delete `dist`, reinstall from the lockfile (`--frozen-lockfile`), and re-run the gates before believing a local green. |
237
+ | Green test suite, broken app | The scenario calls operations directly and never reaches `server.ts`, its routes, or the principal picker. Boot the server and drive the flow over HTTP as two personas — one who should succeed and one who should be denied. |
238
+ | Permission denied after you widened a role | Roles are projected into a scope when that scope is provisioned, from `ROLES` in `src/provision.ts`. An existing scope keeps the projection it was born with — re-provision it (or re-seed onto a fresh data directory), then present the permission diff below. |
239
+ | `pnpm install` dies compiling `better-sqlite3` | Take `better-sqlite3` out of `pnpm.onlyBuiltDependencies`. Since 13.x it ships prebuilt binaries and no install script, but it still ships a `binding.gyp` — so an allowlist entry makes pnpm run a `node-gyp rebuild` that nothing here needs. |
240
+
192
241
  ## Two human checkpoints — you may never self-approve
193
242
 
194
243
  Present these and stop: