@astram/engine 0.2.0 → 0.4.0

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.
Files changed (36) hide show
  1. package/dist/decorators/access-bundle.decorator.d.ts.map +1 -1
  2. package/dist/decorators/object-type.decorator.d.ts +2 -0
  3. package/dist/decorators/object-type.decorator.d.ts.map +1 -1
  4. package/dist/decorators/object-type.decorator.js +3 -0
  5. package/dist/index.d.ts +3 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/pipe/context.d.ts +17 -6
  8. package/dist/pipe/context.d.ts.map +1 -1
  9. package/dist/pipe/record-engine.d.ts +37 -5
  10. package/dist/pipe/record-engine.d.ts.map +1 -1
  11. package/dist/pipe/record-engine.js +206 -75
  12. package/dist/pipe/records-service.d.ts +18 -1
  13. package/dist/pipe/records-service.d.ts.map +1 -1
  14. package/dist/pipe/records-service.js +42 -6
  15. package/dist/pipe/stages/audit.d.ts.map +1 -1
  16. package/dist/pipe/stages/derive-write-context.d.ts.map +1 -1
  17. package/dist/pipe/stages/derive-write-context.js +6 -0
  18. package/dist/pipe/stages/fetch-record.d.ts +54 -0
  19. package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
  20. package/dist/pipe/stages/fetch-record.js +101 -0
  21. package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
  22. package/dist/pipe/stages/permission-gate.js +17 -9
  23. package/dist/pipe/stages/write.d.ts.map +1 -1
  24. package/dist/pipe/stages/write.js +75 -38
  25. package/dist/pipe/where-by-id.d.ts +33 -0
  26. package/dist/pipe/where-by-id.d.ts.map +1 -0
  27. package/dist/pipe/where-by-id.js +69 -0
  28. package/dist/registry.d.ts +32 -1
  29. package/dist/registry.d.ts.map +1 -1
  30. package/dist/registry.js +70 -0
  31. package/dist/types.d.ts +66 -6
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/dist/pipe/stages/fetch-owner.d.ts +0 -28
  35. package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
  36. package/dist/pipe/stages/fetch-owner.js +0 -90
@@ -8,7 +8,7 @@
8
8
  * Runtime-registered (no-code) tables will register through the SAME registry later
9
9
  * (the no-code phase); the pipe does not distinguish how a registration arrived.
10
10
  */
11
- import type { FieldSpec, PkStrategy, ObjectType } from './types.js';
11
+ import type { FieldSpec, PkStrategy, ObjectType, Principal } from './types.js';
12
12
  export declare class TableRegistry {
13
13
  private readonly byType;
14
14
  /** Register a table. Throws on a duplicate typeKey or an invalid registration. */
@@ -37,6 +37,37 @@ export declare function ownerColumn(reg: ObjectType): string | null;
37
37
  export declare function tenantColumnOf(reg: ObjectType): string | null;
38
38
  /** The physical column of the field bearing `role`, or null if unclaimed. */
39
39
  export declare function columnForRole(reg: ObjectType, role: FieldSpec['role']): string | null;
40
+ /**
41
+ * The WRITE-scope predicate for a type's update/delete WHERE (ADR-0030) — the write-path twin of
42
+ * the read wall (`readScope`). For a type declaring `readScope`, it constrains a NON-ROOT actor's
43
+ * update/delete to rows within the actor's reachable subtree, so a targeted write to a
44
+ * foreign-subtree row matches ZERO rows (fail-closed no-op) instead of touching it. It is a
45
+ * FILTER, never a stamp — no column value is set, so the ADR-0027 create-stamping decoupling
46
+ * (add-to-sub-company) is preserved. ADR-0034 reuses the SAME clause for the post-insert create
47
+ * wall (`write.ts`), so update, delete and create share one definition of "inside my subtree".
48
+ *
49
+ * Returns `null` (nothing to AND) when:
50
+ * - the type declares no `readScope`, OR
51
+ * - the actor is ROOT (`isRoot === true`, strict) — root spans all subtrees.
52
+ * Otherwise returns an AND-able fragment (the leading ` AND ` is the CALLER's to prepend — this
53
+ * returns the bare predicate + its `?`-bound params), over the actor's subtree nodes:
54
+ * - `nodeColumn` → `` `<column>` IN (?, ...) ``.
55
+ * - `subtreeSubquery` → `` `<pkColumn>` IN (<subquerySql, :nodes → IN (?, ...)>) ``.
56
+ *
57
+ * INJECTION-FREE BY CONSTRUCTION: identifiers are compile-time constants (the type's own column
58
+ * or the fixed framework template); the ONLY dynamic values are the `?`-bound subtree node ids.
59
+ * Mirrors the engine's private `readScopeClause` exactly (one definition of the subtree predicate).
60
+ */
61
+ export declare function writeScopeClause(reg: ObjectType, actor: Principal): {
62
+ sql: string;
63
+ params: readonly unknown[];
64
+ } | null;
65
+ /**
66
+ * Whether a NON-ROOT create of this type must pass the post-insert create wall (ADR-0034): the
67
+ * type declares `readScope` and has not explicitly opted out with `create: 'unscoped'`. Default
68
+ * walled (fail closed). Root is handled by {@link writeScopeClause} returning `null`.
69
+ */
70
+ export declare function isCreateWalled(reg: ObjectType): boolean;
40
71
  /** Boot-enforce that exactly-one field claims `role`; throw if none. */
41
72
  export declare function requireRole(reg: ObjectType, role: NonNullable<FieldSpec['role']>): void;
42
73
  //# sourceMappingURL=registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEpE,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAExD,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI;IAQ/B,uEAAuE;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAWpC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAI7B,GAAG,IAAI,SAAS,UAAU,EAAE;CAG7B;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAEjD;AAED,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,EAAE,EAAE,UAAU,GAAG,MAAM,CAKnD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEhD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG1D;AAED,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG7D;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAGrF;AAED,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAMvF"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE/E,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAExD,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI;IAQ/B,uEAAuE;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAWpC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAI7B,GAAG,IAAI,SAAS,UAAU,EAAE;CAG7B;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAEjD;AAED,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,EAAE,EAAE,UAAU,GAAG,MAAM,CAKnD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEhD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG1D;AAED,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG7D;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAGrF;AAaD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,SAAS,GACf;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAA;CAAE,GAAG,IAAI,CAYpD;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEvD;AAED,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAMvF"}
package/dist/registry.js CHANGED
@@ -17,8 +17,11 @@ exports.isOwned = isOwned;
17
17
  exports.ownerColumn = ownerColumn;
18
18
  exports.tenantColumnOf = tenantColumnOf;
19
19
  exports.columnForRole = columnForRole;
20
+ exports.writeScopeClause = writeScopeClause;
21
+ exports.isCreateWalled = isCreateWalled;
20
22
  exports.requireRole = requireRole;
21
23
  const errors_js_1 = require("./errors.js");
24
+ const types_js_1 = require("./types.js");
22
25
  class TableRegistry {
23
26
  byType = new Map();
24
27
  /** Register a table. Throws on a duplicate typeKey or an invalid registration. */
@@ -83,6 +86,59 @@ function columnForRole(reg, role) {
83
86
  const f = reg.fields.find((x) => x.role === role);
84
87
  return f?.column ?? f?.key ?? null;
85
88
  }
89
+ /**
90
+ * The subtree node set a non-root actor's scope is bounded to (ADR-0027) — its
91
+ * `scopeSubtreeNodeIds`, resolved once at login and cached. FAIL-SAFE + non-widening: a malformed
92
+ * or absent subtree falls back to just the actor's home node (`[actor.tenantId]`) — today's flat
93
+ * self-isolation; NEVER an empty set. The SAME set the read wall + tenant gate + list-scope use.
94
+ */
95
+ function subtreeNodesOf(actor) {
96
+ const subtree = actor.scopeSubtreeNodeIds;
97
+ return Array.isArray(subtree) && subtree.length > 0 ? subtree : [actor.tenantId];
98
+ }
99
+ /**
100
+ * The WRITE-scope predicate for a type's update/delete WHERE (ADR-0030) — the write-path twin of
101
+ * the read wall (`readScope`). For a type declaring `readScope`, it constrains a NON-ROOT actor's
102
+ * update/delete to rows within the actor's reachable subtree, so a targeted write to a
103
+ * foreign-subtree row matches ZERO rows (fail-closed no-op) instead of touching it. It is a
104
+ * FILTER, never a stamp — no column value is set, so the ADR-0027 create-stamping decoupling
105
+ * (add-to-sub-company) is preserved. ADR-0034 reuses the SAME clause for the post-insert create
106
+ * wall (`write.ts`), so update, delete and create share one definition of "inside my subtree".
107
+ *
108
+ * Returns `null` (nothing to AND) when:
109
+ * - the type declares no `readScope`, OR
110
+ * - the actor is ROOT (`isRoot === true`, strict) — root spans all subtrees.
111
+ * Otherwise returns an AND-able fragment (the leading ` AND ` is the CALLER's to prepend — this
112
+ * returns the bare predicate + its `?`-bound params), over the actor's subtree nodes:
113
+ * - `nodeColumn` → `` `<column>` IN (?, ...) ``.
114
+ * - `subtreeSubquery` → `` `<pkColumn>` IN (<subquerySql, :nodes → IN (?, ...)>) ``.
115
+ *
116
+ * INJECTION-FREE BY CONSTRUCTION: identifiers are compile-time constants (the type's own column
117
+ * or the fixed framework template); the ONLY dynamic values are the `?`-bound subtree node ids.
118
+ * Mirrors the engine's private `readScopeClause` exactly (one definition of the subtree predicate).
119
+ */
120
+ function writeScopeClause(reg, actor) {
121
+ const rs = reg.readScope;
122
+ if (!rs || actor.isRoot === true)
123
+ return null;
124
+ const nodes = subtreeNodesOf(actor);
125
+ const placeholders = nodes.map(() => '?').join(', ');
126
+ if (rs.kind === 'nodeColumn') {
127
+ return { sql: `\`${rs.column}\` IN (${placeholders})`, params: [...nodes] };
128
+ }
129
+ // subtreeSubquery: expand the FIXED template's `:nodes` token to a `?`-list. Identifiers are
130
+ // constants; only the node ids are `?`-bound.
131
+ const expanded = rs.subquerySql.replace(types_js_1.READ_SCOPE_NODES_TOKEN, placeholders);
132
+ return { sql: `\`${rs.pkColumn}\` IN (${expanded})`, params: [...nodes] };
133
+ }
134
+ /**
135
+ * Whether a NON-ROOT create of this type must pass the post-insert create wall (ADR-0034): the
136
+ * type declares `readScope` and has not explicitly opted out with `create: 'unscoped'`. Default
137
+ * walled (fail closed). Root is handled by {@link writeScopeClause} returning `null`.
138
+ */
139
+ function isCreateWalled(reg) {
140
+ return reg.readScope !== undefined && reg.readScope.create !== 'unscoped';
141
+ }
86
142
  /** Boot-enforce that exactly-one field claims `role`; throw if none. */
87
143
  function requireRole(reg, role) {
88
144
  if (!reg.fields.some((f) => f.role === role)) {
@@ -103,6 +159,20 @@ function validate(reg) {
103
159
  throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': duplicate field key '${f.key}'`);
104
160
  }
105
161
  keys.add(f.key);
162
+ // ADR-0034: `immutable` is a security posture (create-only) — a truthy non-boolean is a
163
+ // malformed declaration, never silently read as "mutable".
164
+ const immutable = f.immutable;
165
+ if (immutable !== undefined && typeof immutable !== 'boolean') {
166
+ throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': field '${f.key}' immutable must be a boolean, got ` +
167
+ `${typeof immutable} (${String(immutable)}).`);
168
+ }
169
+ }
170
+ // ADR-0034: the create-wall mode is either absent (walled), 'walled' or 'unscoped'. Anything
171
+ // else fails boot — an opt-out is an explicit decision, never a typo.
172
+ const createMode = reg.readScope?.create;
173
+ if (createMode !== undefined && createMode !== 'walled' && createMode !== 'unscoped') {
174
+ throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': readScope.create must be 'walled' or 'unscoped', got ` +
175
+ `'${String(createMode)}'.`);
106
176
  }
107
177
  if (reg.primaryKey.kind === 'composite' && reg.primaryKey.columns.length < 2) {
108
178
  throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': composite primary key needs >= 2 columns`);
package/dist/types.d.ts CHANGED
@@ -38,6 +38,14 @@ export interface FieldSpec {
38
38
  * password, etc.) and carries no write-time behavior of its own.
39
39
  */
40
40
  readonly role?: IdentityRole;
41
+ /**
42
+ * A CREATE-ONLY field (ADR-0034): written on create, and IGNORED on update exactly like a
43
+ * `trusted` field — an update's value for it never reaches the UPDATE SQL, so the stored value
44
+ * stands. Use it for structural links that a plain patch must never move (a company's parent,
45
+ * a membership's user/company, a posting's membership/role); moving them needs a dedicated,
46
+ * checked operation. Must be a boolean when present (boot-validated).
47
+ */
48
+ readonly immutable?: boolean;
41
49
  }
42
50
  /** Server-trusted write-context sources (derive-write-context stage). */
43
51
  export type TrustedSource = 'tenantId' | 'actorId';
@@ -101,8 +109,18 @@ export interface AccessDeclaration {
101
109
  /**
102
110
  * How a type's LIST/GET reads are scoped to the caller's reachable subtree (ADR-0027) —
103
111
  * DECOUPLED from `trusted:'tenantId'` (which stamps WRITES via derive-write-context).
104
- * `readScope` filters READS ONLY; it has ZERO write effect (a create still stamps only the
105
- * `trusted` fields). Absent ⇒ no subtree read-scope (today's behavior — the tenant clause,
112
+ * `readScope` filters READS, and (ADR-0030) also FILTERS the update/delete WHERE so a non-root
113
+ * actor cannot mutate/delete a row outside its subtree by id — a fail-closed no-op, the write-path
114
+ * twin of the read wall. It is NEVER a create-stamp (a create still stamps only the `trusted`
115
+ * fields — the decoupling that lets an admin add a user to a SUB-company is preserved).
116
+ *
117
+ * CREATE WALL (ADR-0034): a NON-ROOT create of a `readScope` type is re-checked inside the same
118
+ * unit of work right after the INSERT — `SELECT 1 … WHERE <pk> = ? AND <the same write-scope
119
+ * clause>`; zero rows ⇒ `AuthorizationDeniedError` and the transaction rolls back. This is the
120
+ * DEFAULT (`create` absent or `'walled'`): fail closed. A type whose rows are born OUTSIDE every
121
+ * subtree (a new company before the closure lists it, a new user before any membership, a new
122
+ * role before any posting) opts out EXPLICITLY with `create: 'unscoped'`. Absent `readScope` ⇒
123
+ * no subtree read/write-scope (today's behavior — the tenant clause,
106
124
  * if the type has a `trusted:'tenantId'` column, still applies; a `system` type with no
107
125
  * such column then returns unscoped, which is why the identity `system` types declare a
108
126
  * `readScope` to wall themselves). Applied by the engine AFTER the tenant clause, for
@@ -122,8 +140,12 @@ export interface AccessDeclaration {
122
140
  export type ReadScope = {
123
141
  readonly kind: 'nodeColumn';
124
142
  readonly column: string;
143
+ /** Create-wall mode (ADR-0034). Absent ⇒ `'walled'` (fail closed). */
144
+ readonly create?: ReadScopeCreateMode;
125
145
  } | {
126
146
  readonly kind: 'subtreeSubquery';
147
+ /** Create-wall mode (ADR-0034). Absent ⇒ `'walled'` (fail closed). */
148
+ readonly create?: ReadScopeCreateMode;
127
149
  /** The type's OWN PK column to test membership of. */
128
150
  readonly pkColumn: string;
129
151
  /**
@@ -133,6 +155,13 @@ export type ReadScope = {
133
155
  */
134
156
  readonly subquerySql: string;
135
157
  };
158
+ /**
159
+ * How a non-root CREATE of a `readScope` type is checked (ADR-0034):
160
+ * - `'walled'` (the default when absent) — the inserted row must satisfy the write-scope clause,
161
+ * else the create is denied and rolled back.
162
+ * - `'unscoped'` — an explicit opt-out for types whose rows are born outside every subtree.
163
+ */
164
+ export type ReadScopeCreateMode = 'walled' | 'unscoped';
136
165
  /** The token a `subtreeSubquery` template uses for the engine-expanded subtree id list. */
137
166
  export declare const READ_SCOPE_NODES_TOKEN = ":nodes";
138
167
  /** Primary-key strategy for a registered table. */
@@ -176,9 +205,11 @@ export interface ObjectType {
176
205
  readonly access: AccessDeclaration;
177
206
  /**
178
207
  * OPTIONAL read-scope declaration (ADR-0027) — how LIST/GET reads are filtered to the
179
- * caller's reachable subtree, DECOUPLED from the write path. See {@link ReadScope}.
180
- * Absent ⇒ no subtree read-scope (the tenant clause, if any, still applies). Has ZERO
181
- * write effect.
208
+ * caller's reachable subtree. Also FILTERS the update/delete WHERE for a non-root actor
209
+ * (ADR-0030 — a fail-closed no-op on a foreign-subtree target), and WALLS a non-root create
210
+ * (ADR-0034 — denied + rolled back unless `create: 'unscoped'`), but is NEVER a create-stamp.
211
+ * See {@link ReadScope}. Absent ⇒ no subtree read/write-scope (the tenant clause, if any,
212
+ * still applies).
182
213
  */
183
214
  readonly readScope?: ReadScope;
184
215
  }
@@ -225,7 +256,11 @@ export interface ListQuery {
225
256
  readonly where?: Readonly<Record<string, unknown>>;
226
257
  /** Free-text LIKE search across named field keys (ANDed with `where` + tenant). */
227
258
  readonly search?: SearchQuery;
228
- /** A registered field KEY to ORDER BY (unknown → RegistrationError). */
259
+ /**
260
+ * A registered field KEY to ORDER BY (unknown → RegistrationError). The primary-key
261
+ * column(s) are always appended ascending as the final tiebreaker, so OFFSET paging is
262
+ * stable; with no `sortBy` the list is ordered by the primary key.
263
+ */
229
264
  readonly sortBy?: string;
230
265
  /** Sort direction; anything other than 'desc' is treated as ascending. */
231
266
  readonly sortOrder?: 'asc' | 'desc';
@@ -242,10 +277,35 @@ export interface RecordPage {
242
277
  */
243
278
  export interface RecordEngine {
244
279
  create(typeKey: string, input: RecordInput, actor: Principal): Promise<RecordId>;
280
+ /**
281
+ * update/delete judge the EXISTING row (its fields, owner and tenant) in `decide()`. A record
282
+ * the actor cannot reach (missing, other tenant, outside the write scope) is refused with
283
+ * `AuthorizationDeniedError('no_record_permit')`, never a silent no-op. get() judges the same
284
+ * way and returns `null` for an unreachable record of a non-owned type.
285
+ */
245
286
  update(typeKey: string, id: RecordId, input: RecordInput, actor: Principal): Promise<void>;
246
287
  delete(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
247
288
  get(typeKey: string, id: RecordId, actor: Principal): Promise<RecordView | null>;
248
289
  list(typeKey: string, query: ListQuery, actor: Principal): Promise<RecordPage>;
290
+ /**
291
+ * Run `fn` in ONE unit of work (ADR-0034). Every op on the `tx` handle — and every plain call
292
+ * on this engine made from inside `fn` — shares the same transaction; each op still runs the
293
+ * full pipe (gate, validate, write, audit). Commits when `fn` resolves; rolls back when it
294
+ * throws. ROLLBACK-ONLY: once a WRITE op inside the transaction has failed, the transaction can
295
+ * no longer commit (even if `fn` swallowed the error) — the partial write of a failed op is
296
+ * never committed. A nested `transaction` (on the handle or on the engine) reuses the outer one.
297
+ * A handle used after its transaction ended fails closed (`EngineUnavailableError`).
298
+ */
299
+ transaction<T>(fn: (tx: RecordEngine) => Promise<T>): Promise<T>;
300
+ /**
301
+ * Take an exclusive ROW LOCK on one record (`SELECT … WHERE <pk> = ? FOR UPDATE`) on the open
302
+ * transaction's own connection, held until that transaction commits or rolls back. Use it to
303
+ * serialise a check-then-write against a concurrent writer of the same row: lock first, then
304
+ * re-check, then write. Returns nothing (no row data, no existence signal — a missing row is
305
+ * simply not locked), so it runs no read gate or wall. Only valid inside `transaction(fn)`:
306
+ * outside one it throws `EngineUnavailableError` (a lock released at once serialises nothing).
307
+ */
308
+ lockForUpdate(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
249
309
  }
250
310
  export type { Principal };
251
311
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/engine",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the paved road. ONE unbypassable write pipe (resolve-schema → permission gate → derive-write-context → validate → compute → write → junction-sync → audit → emit-event) over real physical tables, that every feature's CRUD flows through. Feature code receives ONLY the RecordEngine facade — never a raw pool/DataSource/QueryRunner (the physical wall). Security-gate + audit are mandatory stages. Missing capability → a typed CapabilityNotSupportedError (the gap-flag STOP). Builds on @astram/core (metadata contract, decide(), audit) + @astram/guard (decision authority).",
6
6
  "type": "commonjs",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "mariadb": "^3.4.0",
24
- "@astram/core": "1.2.0"
24
+ "@astram/core": "1.4.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@nestjs/common": "^11",
@@ -1,28 +0,0 @@
1
- /**
2
- * Stage: fetch-owner (record-level OWNED write ONLY — runs BEFORE the permission gate).
3
- *
4
- * Core's decision authority is zero-dep (no DB) and decides BEFORE the row is fetched,
5
- * so it cannot see a record's owner to check DAC ownership. This stage closes that gap
6
- * for `owned` types on update/delete: it SELECTs the EXISTING row's owner column —
7
- * TENANT-SCOPED in the WHERE so it can never read another tenant's row — and stashes it
8
- * on `ctx.ownerId`. The permission-gate stage then passes it to `decide()` as
9
- * `context.attributes.ownerId`, where core's owner baseline compares it to the actor.
10
- *
11
- * ── EXEMPTIONS (no pre-fetch):
12
- * - `create` — there is no existing record; the owner is being SET by the
13
- * derive-write-context stage, not checked. (This stage no-ops for create.)
14
- * - NON-owned / system types — no owner column to check; the gate runs on the
15
- * grant/system baseline alone. (No unnecessary query.)
16
- *
17
- * ── SECURITY (fail-closed):
18
- * - The row does NOT exist (or is in another tenant) ⇒ `ctx.ownerId` stays undefined
19
- * ⇒ core's owner baseline finds no match ⇒ the gate denies (no_record_permit). A
20
- * missing record NEVER leaks and NEVER passes.
21
- * - This stage only READS the owner column (tenant-scoped) — it performs no write and
22
- * surfaces no row content; the fetched owner is an opaque id fed to the gate.
23
- * - The stage runs inside the SAME transaction as the gate + write (one UoW), so the
24
- * owner it read is the owner the write will act on (no TOCTOU within the txn).
25
- */
26
- import type { Stage } from '../context.js';
27
- export declare function fetchOwnerStage(): Stage;
28
- //# sourceMappingURL=fetch-owner.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"fetch-owner.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/fetch-owner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAqCzD,wBAAgB,eAAe,IAAI,KAAK,CAgCvC"}
@@ -1,90 +0,0 @@
1
- "use strict";
2
- /**
3
- * Stage: fetch-owner (record-level OWNED write ONLY — runs BEFORE the permission gate).
4
- *
5
- * Core's decision authority is zero-dep (no DB) and decides BEFORE the row is fetched,
6
- * so it cannot see a record's owner to check DAC ownership. This stage closes that gap
7
- * for `owned` types on update/delete: it SELECTs the EXISTING row's owner column —
8
- * TENANT-SCOPED in the WHERE so it can never read another tenant's row — and stashes it
9
- * on `ctx.ownerId`. The permission-gate stage then passes it to `decide()` as
10
- * `context.attributes.ownerId`, where core's owner baseline compares it to the actor.
11
- *
12
- * ── EXEMPTIONS (no pre-fetch):
13
- * - `create` — there is no existing record; the owner is being SET by the
14
- * derive-write-context stage, not checked. (This stage no-ops for create.)
15
- * - NON-owned / system types — no owner column to check; the gate runs on the
16
- * grant/system baseline alone. (No unnecessary query.)
17
- *
18
- * ── SECURITY (fail-closed):
19
- * - The row does NOT exist (or is in another tenant) ⇒ `ctx.ownerId` stays undefined
20
- * ⇒ core's owner baseline finds no match ⇒ the gate denies (no_record_permit). A
21
- * missing record NEVER leaks and NEVER passes.
22
- * - This stage only READS the owner column (tenant-scoped) — it performs no write and
23
- * surfaces no row content; the fetched owner is an opaque id fed to the gate.
24
- * - The stage runs inside the SAME transaction as the gate + write (one UoW), so the
25
- * owner it read is the owner the write will act on (no TOCTOU within the txn).
26
- */
27
- Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.fetchOwnerStage = fetchOwnerStage;
29
- const errors_js_1 = require("../../errors.js");
30
- const registry_js_1 = require("../../registry.js");
31
- function qualified(ctx) {
32
- const schema = ctx.registration.schema;
33
- const t = ctx.registration.table;
34
- return schema ? `\`${schema}\`.\`${t}\`` : `\`${t}\``;
35
- }
36
- /** WHERE (pk [+ tenant]) for the target id — the SAME tenant-scoping the write stage uses. */
37
- function whereById(ctx, id) {
38
- const pk = ctx.registration.primaryKey;
39
- let sql;
40
- let params;
41
- if (pk.kind === 'uuid') {
42
- if (typeof id !== 'string') {
43
- throw new errors_js_1.RegistrationError(`uuid PK table '${ctx.registration.typeKey}' needs a string id`);
44
- }
45
- sql = `\`${(0, registry_js_1.uuidPkColumn)(pk)}\` = ?`;
46
- params = [id];
47
- }
48
- else {
49
- if (typeof id === 'string') {
50
- throw new errors_js_1.RegistrationError(`composite PK table '${ctx.registration.typeKey}' needs a key map, not a string id`);
51
- }
52
- sql = pk.columns.map((c) => `\`${c}\` = ?`).join(' AND ');
53
- params = pk.columns.map((c) => id[c]);
54
- }
55
- const tenantCol = (0, registry_js_1.tenantColumnOf)(ctx.registration);
56
- if (tenantCol) {
57
- sql += ` AND \`${tenantCol}\` = ?`;
58
- params = [...params, ctx.actor.tenantId];
59
- }
60
- return { sql, params };
61
- }
62
- function fetchOwnerStage() {
63
- return {
64
- name: 'fetch-owner',
65
- async run(ctx) {
66
- // Only OWNED update/delete pre-fetch. create + non-owned types no-op.
67
- if (ctx.op === 'create')
68
- return;
69
- if (!(0, registry_js_1.isOwned)(ctx.registration))
70
- return;
71
- if (ctx.id === undefined)
72
- return;
73
- const ownerCol = (0, registry_js_1.ownerColumn)(ctx.registration);
74
- if (!ownerCol) {
75
- // An owned type without an actorId column is a boot-time impossibility
76
- // (chokepoint 1). Fail-closed rather than silently skipping the owner check.
77
- throw new errors_js_1.RegistrationError(`owned type '${ctx.registration.typeKey}' has no trusted:'actorId' owner column`);
78
- }
79
- const where = whereById(ctx, ctx.id);
80
- const rows = (await ctx.query(`SELECT \`${ownerCol}\` AS owner_id FROM ${qualified(ctx)} WHERE ${where.sql} LIMIT 1`, where.params));
81
- const first = rows[0];
82
- // Missing row (or wrong tenant) ⇒ ownerId stays undefined ⇒ the gate denies.
83
- // Only stash a genuine string owner; a null/non-string owner is left undefined so
84
- // the baseline's strict === can never spuriously match.
85
- if (first && typeof first.owner_id === 'string') {
86
- ctx.ownerId = first.owner_id;
87
- }
88
- },
89
- };
90
- }