create-substrat 0.8.3 → 0.8.5

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.94.0';
40
- const ENGINE_WORKORDER = '^0.10.2';
41
- const ENGINE_INVOICING = '^0.9.10';
42
- const BOUNDARY_LINT = '^0.2.1';
43
- const DEV_ISSUER = '^0.1.8';
39
+ const SUBSTRAT = '^0.97.0';
40
+ const ENGINE_WORKORDER = '^0.10.5';
41
+ const ENGINE_INVOICING = '^0.9.13';
42
+ const BOUNDARY_LINT = '^0.3.0';
43
+ const DEV_ISSUER = '^0.1.12';
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.3",
3
+ "version": "0.8.5",
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`** —
@@ -108,7 +108,14 @@ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
108
108
  handler that runs outside the transaction. An integration is never impossible because
109
109
  of this rule — it has an answer.
110
110
  3. **Never write `_substrat_*` tables.** Reads are fine (timelines are projections);
111
- writes forge the audit spine.
111
+ writes forge the audit spine. Do the read with `readTimeline` / `readHistory` from
112
+ `@substrat-run/kernel` rather than a `SELECT` of your own: both take an `EntityRef`,
113
+ page like a list read, and decode the envelope for you. `readHistory` also returns the
114
+ payload, the authorization chain (which checks the operation passed, and under which
115
+ grant), the impersonation stamp and the PII class. On the first three, a `null` is a
116
+ *fact* rather than data you failed to fetch — the payload was erased, the row predates
117
+ authorization recording, nobody was impersonating — so render it as that. Neither
118
+ helper checks a permission; you do, before you call it.
112
119
  4. **Another module's tables are private.** Never `SELECT` from `workorder_*` etc. — use
113
120
  the engine's exported in-scope functions. This is the rule with no runtime equivalent:
114
121
  the shortcut *works* and silently welds you to an engine's private schema forever. Need
@@ -129,12 +136,46 @@ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
129
136
  (`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
130
137
  10. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
131
138
  hand-roll a hash to dodge an import ban.
132
- 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
133
144
  `@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
134
145
  copies or majors; composing a contracts schema into one built from a separate `zod`
135
146
  fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
136
147
  cause.
137
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
+
138
179
  ## Declare every link edge
139
180
 
140
181
  `entityRelations` in the manifest must declare every edge you traverse — both your own