create-substrat 0.7.1 → 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 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.84.0';
39
- const ENGINE_WORKORDER = '^0.8.0';
40
- const ENGINE_INVOICING = '^0.9.0';
41
- const BOUNDARY_LINT = '^0.1.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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,
@@ -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[]> = async (ctx) => {
249
- const visible: WorkOrder[] = [];
250
- for (const order of listOrders(ctx)) {
251
- const decision = await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id });
252
- if (decision.allowed) visible.push(order);
253
- }
254
- return visible;
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 expect(lisbeth.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toHaveLength(1);
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 expect(rutger.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toEqual([]);
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<{ id: string; status: string; total: string }[]>(
157
- 'invoicing/list',
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[]>('shop/portal-repairs');
182
- expect(hers.map((o) => o.id)).toEqual([repairId]);
183
- await expect(otto.invoke<WorkOrder[]>('shop/portal-repairs')).resolves.toEqual([]);
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 [underlag] = await greta.invoke<{ id: string }[]>('invoicing/list');
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 }[]>('invoicing/list');
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);