@astram/engine 0.3.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 (57) 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 -52
  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 +8 -1
  29. package/dist/registry.d.ts.map +1 -1
  30. package/dist/registry.js +25 -1
  31. package/dist/types.d.ts +59 -3
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/dist/adapters/mariadb-audit-sink.js.map +0 -1
  35. package/dist/adapters/mariadb-unit-of-work.js.map +0 -1
  36. package/dist/decorators/access-bundle.decorator.js.map +0 -1
  37. package/dist/decorators/object-type.decorator.js.map +0 -1
  38. package/dist/errors.js.map +0 -1
  39. package/dist/index.js.map +0 -1
  40. package/dist/module/engine.module.js.map +0 -1
  41. package/dist/module/tokens.js.map +0 -1
  42. package/dist/pipe/context.js.map +0 -1
  43. package/dist/pipe/record-engine.js.map +0 -1
  44. package/dist/pipe/records-service.js.map +0 -1
  45. package/dist/pipe/stages/audit.js.map +0 -1
  46. package/dist/pipe/stages/derive-write-context.js.map +0 -1
  47. package/dist/pipe/stages/fetch-owner.d.ts +0 -28
  48. package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
  49. package/dist/pipe/stages/fetch-owner.js +0 -99
  50. package/dist/pipe/stages/fetch-owner.js.map +0 -1
  51. package/dist/pipe/stages/junction-sync.js.map +0 -1
  52. package/dist/pipe/stages/permission-gate.js.map +0 -1
  53. package/dist/pipe/stages/validate.js.map +0 -1
  54. package/dist/pipe/stages/write.js.map +0 -1
  55. package/dist/ports.js.map +0 -1
  56. package/dist/registry.js.map +0 -1
  57. package/dist/types.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"access-bundle.decorator.d.ts","sourceRoot":"","sources":["../../src/decorators/access-bundle.decorator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,kBAAkB,CAAC;AAW1B;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,+CAA+C;AAC/C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,SAAS,sBAAsB,EAAE,CAAC;CACzD;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5C;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,cAAc,CAItE;AAID;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,QAAQ,GAAG,gBAAgB,CAsCnE"}
1
+ {"version":3,"file":"access-bundle.decorator.d.ts","sourceRoot":"","sources":["../../src/decorators/access-bundle.decorator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,kBAAkB,CAAC;AAW1B;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,+CAA+C;AAC/C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,SAAS,sBAAsB,EAAE,CAAC;CACzD;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5C;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,cAAc,CAItE;AAID;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,QAAQ,GAAG,gBAAgB,CA8BnE"}
@@ -59,6 +59,8 @@ export interface FieldOptions {
59
59
  readonly required?: boolean;
60
60
  readonly trusted?: TrustedSource;
61
61
  readonly role?: IdentityRole;
62
+ /** Create-only (ADR-0034): ignored on update. See `FieldSpec.immutable`. */
63
+ readonly immutable?: boolean;
62
64
  }
63
65
  /**
64
66
  * `@PrimaryKey` options — the {@link PkStrategy} discriminated union verbatim, so it
@@ -1 +1 @@
1
- {"version":3,"file":"object-type.decorator.d.ts","sourceRoot":"","sources":["../../src/decorators/object-type.decorator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EACV,iBAAiB,EAEjB,SAAS,EACT,YAAY,EACZ,YAAY,EAKZ,UAAU,IAAI,kBAAkB,EAChC,UAAU,EACV,SAAS,EACT,aAAa,EACd,MAAM,aAAa,CAAC;AAarB;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,YAAY,EAAE,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;CAChC;AAED,wFAAwF;AACxF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,IAAI,CAAC,EAAE,YAAY,CAAC;CAC9B;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,UAAU,CAAC;AAI3C;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,cAAc,CAIlE;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,UAAW,SAAQ,kBAAkB;CAAG;AAEzD;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,YAAY,GAAG,iBAAiB,CAQ3D;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,cAAc,CAIlE;AAqBD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,QAAQ,GAAG,kBAAkB,CAwCnE"}
1
+ {"version":3,"file":"object-type.decorator.d.ts","sourceRoot":"","sources":["../../src/decorators/object-type.decorator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EACV,iBAAiB,EAEjB,SAAS,EACT,YAAY,EACZ,YAAY,EAKZ,UAAU,IAAI,kBAAkB,EAChC,UAAU,EACV,SAAS,EACT,aAAa,EACd,MAAM,aAAa,CAAC;AAarB;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,YAAY,EAAE,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;CAChC;AAED,wFAAwF;AACxF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,IAAI,CAAC,EAAE,YAAY,CAAC;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,UAAU,CAAC;AAI3C;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,cAAc,CAIlE;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,UAAW,SAAQ,kBAAkB;CAAG;AAEzD;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,YAAY,GAAG,iBAAiB,CAO3D;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,cAAc,CAIlE;AAuBD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,QAAQ,GAAG,kBAAkB,CA+BnE"}
@@ -103,6 +103,9 @@ function reflectField(spec) {
103
103
  f.trusted = spec.trusted;
104
104
  if (spec.role !== undefined)
105
105
  f.role = spec.role;
106
+ // Reflected AS-IS when present (a non-boolean is rejected by the registry at boot).
107
+ if (spec.immutable !== undefined)
108
+ f.immutable = spec.immutable;
106
109
  return f;
107
110
  }
108
111
  /**
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * the `TableRegistry`, and the ports. Exported so the framework + integration
16
16
  * tests can compose the engine directly; NOT for feature code.
17
17
  */
18
- export type { RecordEngine, AccessDeclaration, FieldSpec, FieldType, TrustedSource, IdentityRole, PkStrategy, JunctionSpec, RecordInput, RecordId, RecordView, ListQuery, RecordPage, Principal, ReadScope, } from './types.js';
18
+ export type { RecordEngine, AccessDeclaration, FieldSpec, FieldType, TrustedSource, IdentityRole, PkStrategy, JunctionSpec, RecordInput, RecordId, RecordView, ListQuery, RecordPage, Principal, ReadScope, ReadScopeCreateMode, } from './types.js';
19
19
  export { READ_SCOPE_NODES_TOKEN } from './types.js';
20
20
  export { EngineError, CapabilityNotSupportedError, RegistrationError, AuthorizationDeniedError, ValidationError, EngineUnavailableError, } from './errors.js';
21
21
  export { ObjectType, Field, PrimaryKey, reflectObjectType, type ObjectTypeOptions, type FieldOptions, type PrimaryKeyOptions, } from './decorators/object-type.decorator.js';
@@ -24,8 +24,8 @@ export { EngineModule, RECORD_ENGINE, ENGINE_ALL_REGISTRATIONS, ENGINE_ALL_ACCES
24
24
  export { TableRegistry, columnOf, uuidPkColumn, isOwned, ownerColumn, tenantColumnOf, columnForRole, requireRole, } from './registry.js';
25
25
  export { RecordEngineImpl, type RecordEngineDeps } from './pipe/record-engine.js';
26
26
  export { RecordsService, BoundRecords, type ServiceAccountResolver, SERVICE_ACCOUNT_RESOLVER, } from './pipe/records-service.js';
27
- export { MariaDbUnitOfWork, type EngineDbConfig, } from './adapters/mariadb-unit-of-work.js';
27
+ export { MariaDbUnitOfWork, type EngineDbConfig } from './adapters/mariadb-unit-of-work.js';
28
28
  export { MariaDbAuditSink, type AuditSinkConfig } from './adapters/mariadb-audit-sink.js';
29
- export type { QueryFn, UnitOfWork, DecisionPort, AuditPort, } from './ports.js';
29
+ export type { QueryFn, UnitOfWork, DecisionPort, AuditPort } from './ports.js';
30
30
  export type { Stage, WriteContext, WriteOp, PipeBase } from './pipe/context.js';
31
31
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,YAAY,EACV,YAAY,EACZ,iBAAiB,EACjB,SAAS,EACT,SAAS,EACT,aAAa,EACb,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAQ,EACR,UAAU,EACV,SAAS,EACT,UAAU,EACV,SAAS,EACT,SAAS,GACV,MAAM,YAAY,CAAC;AAKpB,OAAO,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAEpD,OAAO,EACL,WAAW,EACX,2BAA2B,EAC3B,iBAAiB,EACjB,wBAAwB,EACxB,eAAe,EACf,sBAAsB,GACvB,MAAM,aAAa,CAAC;AAiBrB,OAAO,EACL,UAAU,EACV,KAAK,EACL,UAAU,EACV,iBAAiB,EACjB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,uCAAuC,CAAC;AAO/C,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,gBAAgB,GACtB,MAAM,yCAAyC,CAAC;AAEjD,OAAO,EACL,YAAY,EACZ,aAAa,EACb,wBAAwB,EACxB,yBAAyB,EACzB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EACL,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,OAAO,EACP,WAAW,EACX,cAAc,EACd,aAAa,EACb,WAAW,GACZ,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAClF,OAAO,EACL,cAAc,EACd,YAAY,EACZ,KAAK,sBAAsB,EAC3B,wBAAwB,GACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,iBAAiB,EACjB,KAAK,cAAc,GACpB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAC1F,YAAY,EACV,OAAO,EACP,UAAU,EACV,YAAY,EACZ,SAAS,GACV,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,YAAY,EACV,YAAY,EACZ,iBAAiB,EACjB,SAAS,EACT,SAAS,EACT,aAAa,EACb,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAQ,EACR,UAAU,EACV,SAAS,EACT,UAAU,EACV,SAAS,EACT,SAAS,EACT,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAKpB,OAAO,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAEpD,OAAO,EACL,WAAW,EACX,2BAA2B,EAC3B,iBAAiB,EACjB,wBAAwB,EACxB,eAAe,EACf,sBAAsB,GACvB,MAAM,aAAa,CAAC;AAiBrB,OAAO,EACL,UAAU,EACV,KAAK,EACL,UAAU,EACV,iBAAiB,EACjB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,uCAAuC,CAAC;AAO/C,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,gBAAgB,GACtB,MAAM,yCAAyC,CAAC;AAEjD,OAAO,EACL,YAAY,EACZ,aAAa,EACb,wBAAwB,EACxB,yBAAyB,EACzB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EACL,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,OAAO,EACP,WAAW,EACX,cAAc,EACd,aAAa,EACb,WAAW,GACZ,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAClF,OAAO,EACL,cAAc,EACd,YAAY,EACZ,KAAK,sBAAsB,EAC3B,wBAAwB,GACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,iBAAiB,EAAE,KAAK,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC5F,OAAO,EAAE,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAC1F,YAAY,EAAE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC/E,YAAY,EAAE,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC"}
@@ -35,14 +35,25 @@ export interface WriteContext extends PipeBase {
35
35
  /** The id assigned/targeted by the write stage (set for create). */
36
36
  resultId?: RecordId;
37
37
  /**
38
- * For an OWNED type's update/delete: the existing row's owner id, fetched
39
- * tenant-scoped by the fetch-owner stage BEFORE the permission gate, and passed to
40
- * `decide()` as `context.attributes.ownerId` so core's owner baseline can compare it
41
- * to the actor. `undefined` when the type is not owned OR the target row does not
42
- * exist in the actor's tenant (a missing row ⇒ absent owner ⇒ the baseline won't
43
- * match ⇒ the gate denies; the row is never surfaced). NEVER set for create.
38
+ * For update/delete: the EXISTING row (the pre-image, D9) projected to registered field keys,
39
+ * read by the fetch-record stage BEFORE the permission gate with `SELECT * … FOR UPDATE`
40
+ * through the shared `whereById`. The gate passes it to `decide()` as
41
+ * `context.attributes`. A missing row never reaches the gate: fetch-record throws. NEVER set
42
+ * for create.
43
+ */
44
+ existing?: Record<string, unknown>;
45
+ /**
46
+ * For update/delete: the existing row's `trusted:'actorId'` (owner) column, when it is a
47
+ * string. Passed to `decide()` as `context.attributes.ownerId` for core's owner baseline.
48
+ * NEVER set for create.
44
49
  */
45
50
  ownerId?: string;
51
+ /**
52
+ * For update/delete: the existing row's `trusted:'tenantId'` column, when it is a string. The
53
+ * gate judges the record at ITS tenant (`resource.tenantId`); a type with no tenant column
54
+ * falls back to the actor's tenant. NEVER set for create.
55
+ */
56
+ recordTenantId?: string;
46
57
  }
47
58
  /** A stage: a small, ordered, independently-testable unit of the pipe. */
48
59
  export interface Stage {
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/pipe/context.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,gDAAgD;AAChD,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAErD,6DAA6D;AAC7D,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,UAAU,CAAC;IAClC,oFAAoF;IACpF,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,kCAAkC;AAClC,MAAM,WAAW,YAAa,SAAQ,QAAQ;IAC5C,+CAA+C;IAC/C,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd,8CAA8C;IAC9C,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAC7C,oEAAoE;IACpE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,0EAA0E;AAC1E,MAAM,WAAW,KAAK;IACpB,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,GAAG,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,kEAAkE;AAClE,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAO5F"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/pipe/context.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,gDAAgD;AAChD,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAErD,6DAA6D;AAC7D,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,UAAU,CAAC;IAClC,oFAAoF;IACpF,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,kCAAkC;AAClC,MAAM,WAAW,YAAa,SAAQ,QAAQ;IAC5C,+CAA+C;IAC/C,EAAE,CAAC,EAAE,QAAQ,CAAC;IACd,8CAA8C;IAC9C,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAC7C,oEAAoE;IACpE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,0EAA0E;AAC1E,MAAM,WAAW,KAAK;IACpB,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,GAAG,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,kEAAkE;AAClE,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAO5F"}
@@ -3,15 +3,16 @@
3
3
  *
4
4
  * Every write (create/update/delete) runs the FIXED, ordered stage sequence inside
5
5
  * ONE transaction:
6
- * permission-gate (MANDATORY) → derive-write-context → validate → compute (no-op
7
- * Phase 1) → write → junction-sync → audit (MANDATORY) → emit-event (no-op).
6
+ * fetch-record (update/delete) → permission-gate (MANDATORY) → derive-write-context →
7
+ * validate → compute (no-op Phase 1) → write → junction-sync → audit (MANDATORY) →
8
+ * emit-event (no-op).
8
9
  * A caller cannot reorder, add, or drop stages — the pipe shape is fixed here, so
9
10
  * the security gate + audit ALWAYS run (fixes ORS's optional gate + off-road writes).
10
11
  * Any stage throwing aborts the transaction (rollback) — fail-closed.
11
12
  *
12
- * Reads (get/list) run a lean read path: permission-gate (read) → query → project.
13
- * (Row/column access-filtering + ref/label projection are Phase-2 enrichments; the
14
- * Phase-1 read still gates on decide() and never surfaces raw internal ids as labels.)
13
+ * Reads: get() is fetch → gate (on the row's fields + tenant) → project the same row;
14
+ * list() is gate (object type) → SQL-scoped query (list scope fragment, no fetch-then-
15
+ * discard) → project, ordered with a PK tiebreaker. (Column projection is still pending, G2.)
15
16
  */
16
17
  import type { TableRegistry } from '../registry.js';
17
18
  import type { AuditPort, DecisionPort, UnitOfWork, QueryFn } from '../ports.js';
@@ -75,17 +76,48 @@ export declare class RecordEngineImpl implements RecordEngine {
75
76
  private readonly listScope;
76
77
  /** The FIXED write-stage order. Built once; identical for every write. */
77
78
  private readonly writeStages;
79
+ /** The ambient `transaction(fn)` (ADR-0034) so plain calls made inside `fn` join it. */
80
+ private readonly activeTx;
78
81
  constructor(deps: RecordEngineDeps);
82
+ /**
83
+ * Run `fn` in ONE unit of work: every op on the handle, and every plain call on this engine
84
+ * made from inside `fn` (found via AsyncLocalStorage), shares the transaction. Each op still
85
+ * runs its full pipe. Commit on resolve, rollback on throw. ROLLBACK-ONLY once any write op
86
+ * (or a nested `fn`) inside it has failed, even if the caller swallowed the error — so the
87
+ * INSERT of a create the wall denied can never be committed by a careless `catch`. Nested calls
88
+ * reuse the outer transaction; a handle used after its transaction ended fails closed.
89
+ */
90
+ transaction<T>(fn: (tx: RecordEngine) => Promise<T>): Promise<T>;
91
+ private transactionIn;
92
+ /** The transaction-bound engine handle passed to `transaction(fn)`. */
93
+ private handleFor;
94
+ /**
95
+ * Row lock on one record inside the open transaction (see `RecordEngine.lockForUpdate`). Keyed
96
+ * by the primary key only and returning nothing, so it reveals no data and needs no read gate.
97
+ */
98
+ lockForUpdate(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
99
+ private lockIn;
100
+ /**
101
+ * Run one op's body in the right unit of work: the bound/ambient transaction when there is
102
+ * one, else a fresh `uow.run` (the original per-op transaction). A WRITE failure inside a
103
+ * shared transaction marks it rollback-only.
104
+ */
105
+ private inUnit;
79
106
  private runWrite;
80
107
  create(typeKey: string, input: RecordInput, actor: Principal): Promise<RecordId>;
81
108
  update(typeKey: string, id: RecordId, input: RecordInput, actor: Principal): Promise<void>;
82
109
  delete(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
110
+ private createIn;
111
+ private updateIn;
112
+ private deleteIn;
83
113
  private gateRead;
84
114
  private qualified;
85
115
  /** Project a raw DB row → RecordView (field keys, not columns). */
86
116
  private project;
87
117
  get(typeKey: string, id: RecordId, actor: Principal): Promise<RecordView | null>;
88
118
  list(typeKey: string, query: ListQuery, actor: Principal): Promise<RecordPage>;
119
+ private getIn;
120
+ private listIn;
89
121
  /** The physical column of a `tenantId`-trusted field, if the table has one. */
90
122
  private tenantColumn;
91
123
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"record-engine.d.ts","sourceRoot":"","sources":["../../src/pipe/record-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAEhF,OAAO,KAAK,EACV,SAAS,EACT,SAAS,EACT,YAAY,EACZ,QAAQ,EACR,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACX,MAAM,aAAa,CAAC;AAYrB,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,GAAG,EAAE,UAAU,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IAC9B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CACzB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CACnB,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,SAAS,EAChB,MAAM,EAAE,MAAM,KACX,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAA;KAAE,CAAC,CAAC;CAC3D;AAED,qBAAa,gBAAiB,YAAW,YAAY;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAe;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAY;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAOlB;IACd,OAAO,CAAC,QAAQ,CAAC,SAAS,CAMZ;IACd,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAmB;gBAEnC,IAAI,EAAE,gBAAgB;YAyBpB,QAAQ;IAsChB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAC;IAMhF,MAAM,CACV,OAAO,EAAE,MAAM,EACf,EAAE,EAAE,QAAQ,EACZ,KAAK,EAAE,WAAW,EAClB,KAAK,EAAE,SAAS,GACf,OAAO,CAAC,IAAI,CAAC;IAIV,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;YAM9D,QAAQ;IAiCtB,OAAO,CAAC,SAAS;IAIjB,mEAAmE;IACnE,OAAO,CAAC,OAAO;IAiBT,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA4EhF,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAsHpF,+EAA+E;IAC/E,OAAO,CAAC,YAAY;IAKpB;;;;;;;;OAQG;IACH,OAAO,CAAC,gBAAgB;IAKxB;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,eAAe;CAmBxB"}
1
+ {"version":3,"file":"record-engine.d.ts","sourceRoot":"","sources":["../../src/pipe/record-engine.ts"],"names":[],"mappings":"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"}
@@ -4,23 +4,26 @@
4
4
  *
5
5
  * Every write (create/update/delete) runs the FIXED, ordered stage sequence inside
6
6
  * ONE transaction:
7
- * permission-gate (MANDATORY) → derive-write-context → validate → compute (no-op
8
- * Phase 1) → write → junction-sync → audit (MANDATORY) → emit-event (no-op).
7
+ * fetch-record (update/delete) → permission-gate (MANDATORY) → derive-write-context →
8
+ * validate → compute (no-op Phase 1) → write → junction-sync → audit (MANDATORY) →
9
+ * emit-event (no-op).
9
10
  * A caller cannot reorder, add, or drop stages — the pipe shape is fixed here, so
10
11
  * the security gate + audit ALWAYS run (fixes ORS's optional gate + off-road writes).
11
12
  * Any stage throwing aborts the transaction (rollback) — fail-closed.
12
13
  *
13
- * Reads (get/list) run a lean read path: permission-gate (read) → query → project.
14
- * (Row/column access-filtering + ref/label projection are Phase-2 enrichments; the
15
- * Phase-1 read still gates on decide() and never surfaces raw internal ids as labels.)
14
+ * Reads: get() is fetch → gate (on the row's fields + tenant) → project the same row;
15
+ * list() is gate (object type) → SQL-scoped query (list scope fragment, no fetch-then-
16
+ * discard) → project, ordered with a PK tiebreaker. (Column projection is still pending, G2.)
16
17
  */
17
18
  Object.defineProperty(exports, "__esModule", { value: true });
18
19
  exports.RecordEngineImpl = void 0;
20
+ const node_async_hooks_1 = require("node:async_hooks");
19
21
  const errors_js_1 = require("../errors.js");
20
22
  const registry_js_1 = require("../registry.js");
21
23
  const types_js_1 = require("../types.js");
22
24
  const context_js_1 = require("./context.js");
23
- const fetch_owner_js_1 = require("./stages/fetch-owner.js");
25
+ const fetch_record_js_1 = require("./stages/fetch-record.js");
26
+ const where_by_id_js_1 = require("./where-by-id.js");
24
27
  const permission_gate_js_1 = require("./stages/permission-gate.js");
25
28
  const derive_write_context_js_1 = require("./stages/derive-write-context.js");
26
29
  const validate_js_1 = require("./stages/validate.js");
@@ -28,6 +31,16 @@ const write_js_1 = require("./stages/write.js");
28
31
  const junction_sync_js_1 = require("./stages/junction-sync.js");
29
32
  const audit_js_1 = require("./stages/audit.js");
30
33
  const errors_js_2 = require("../errors.js");
34
+ /** Fail closed when a transaction handle outlives its transaction or is already doomed. */
35
+ function assertUsable(tx) {
36
+ if (!tx.open) {
37
+ throw new errors_js_2.EngineUnavailableError('record transaction has already ended; a transaction handle cannot be used after its ' +
38
+ 'transaction(fn) settled.');
39
+ }
40
+ if (tx.failure !== undefined) {
41
+ throw new errors_js_2.EngineUnavailableError('record transaction is rollback-only: an earlier write inside it failed.');
42
+ }
43
+ }
31
44
  class RecordEngineImpl {
32
45
  registry;
33
46
  uow;
@@ -38,6 +51,8 @@ class RecordEngineImpl {
38
51
  listScope;
39
52
  /** The FIXED write-stage order. Built once; identical for every write. */
40
53
  writeStages;
54
+ /** The ambient `transaction(fn)` (ADR-0034) so plain calls made inside `fn` join it. */
55
+ activeTx = new node_async_hooks_1.AsyncLocalStorage();
41
56
  constructor(deps) {
42
57
  this.registry = deps.registry;
43
58
  this.uow = deps.uow;
@@ -47,11 +62,11 @@ class RecordEngineImpl {
47
62
  this.onRecordDeleted = deps.onRecordDeleted;
48
63
  this.listScope = deps.listScope;
49
64
  // permission-gate + audit are ALWAYS present, in this order — no skip flag.
50
- // fetch-owner runs FIRST (before the gate) so an OWNED update/delete's existing-row
51
- // owner is available to decide() as context.attributes.ownerId (core owner baseline).
52
- // It no-ops for create + non-owned types (no unnecessary query).
65
+ // fetch-record runs FIRST (before the gate): for update/delete on EVERY type it row-locks
66
+ // and reads the existing row through the shared whereById, so decide() sees the record's
67
+ // fields, owner and tenant (block D, #10). A missing row throws there. No-op for create.
53
68
  this.writeStages = [
54
- (0, fetch_owner_js_1.fetchOwnerStage)(),
69
+ (0, fetch_record_js_1.fetchRecordStage)(),
55
70
  (0, permission_gate_js_1.permissionGateStage)(this.decision),
56
71
  (0, derive_write_context_js_1.deriveWriteContextStage)(),
57
72
  (0, validate_js_1.validateStage)(),
@@ -62,10 +77,100 @@ class RecordEngineImpl {
62
77
  // emit-event stage: Phase-1 no-op (business rules land later)
63
78
  ];
64
79
  }
65
- async runWrite(op, typeKey, actor, opts) {
80
+ // --- transactions (ADR-0034) ---------------------------------------------------
81
+ /**
82
+ * Run `fn` in ONE unit of work: every op on the handle, and every plain call on this engine
83
+ * made from inside `fn` (found via AsyncLocalStorage), shares the transaction. Each op still
84
+ * runs its full pipe. Commit on resolve, rollback on throw. ROLLBACK-ONLY once any write op
85
+ * (or a nested `fn`) inside it has failed, even if the caller swallowed the error — so the
86
+ * INSERT of a create the wall denied can never be committed by a careless `catch`. Nested calls
87
+ * reuse the outer transaction; a handle used after its transaction ended fails closed.
88
+ */
89
+ async transaction(fn) {
90
+ return this.transactionIn(undefined, fn);
91
+ }
92
+ async transactionIn(bound, fn) {
93
+ const outer = bound ?? this.activeTx.getStore();
94
+ if (outer !== undefined) {
95
+ // NESTED: reuse the outer unit of work. A throw out of the nested fn marks the outer
96
+ // transaction rollback-only (its writes must not commit just because an outer catch ran).
97
+ assertUsable(outer);
98
+ try {
99
+ return await fn(this.handleFor(outer));
100
+ }
101
+ catch (err) {
102
+ outer.failure ??= err;
103
+ throw err;
104
+ }
105
+ }
106
+ return this.uow.run(async (query) => {
107
+ const state = { query, open: true, failure: undefined };
108
+ try {
109
+ const out = await this.activeTx.run(state, () => fn(this.handleFor(state)));
110
+ // Rollback-only: a write failed inside fn and the caller swallowed it ⇒ re-raise it so
111
+ // the unit of work rolls back instead of committing a partial write.
112
+ if (state.failure !== undefined)
113
+ throw state.failure;
114
+ return out;
115
+ }
116
+ finally {
117
+ state.open = false;
118
+ }
119
+ });
120
+ }
121
+ /** The transaction-bound engine handle passed to `transaction(fn)`. */
122
+ handleFor(state) {
123
+ return {
124
+ create: (typeKey, input, actor) => this.createIn(state, typeKey, input, actor),
125
+ update: (typeKey, id, input, actor) => this.updateIn(state, typeKey, id, input, actor),
126
+ delete: (typeKey, id, actor) => this.deleteIn(state, typeKey, id, actor),
127
+ get: (typeKey, id, actor) => this.getIn(state, typeKey, id, actor),
128
+ list: (typeKey, query, actor) => this.listIn(state, typeKey, query, actor),
129
+ transaction: (fn) => this.transactionIn(state, fn),
130
+ lockForUpdate: (typeKey, id, actor) => this.lockIn(state, typeKey, id, actor),
131
+ };
132
+ }
133
+ /**
134
+ * Row lock on one record inside the open transaction (see `RecordEngine.lockForUpdate`). Keyed
135
+ * by the primary key only and returning nothing, so it reveals no data and needs no read gate.
136
+ */
137
+ async lockForUpdate(typeKey, id, actor) {
138
+ return this.lockIn(undefined, typeKey, id, actor);
139
+ }
140
+ async lockIn(bound, typeKey, id, _actor) {
141
+ const tx = bound ?? this.activeTx.getStore();
142
+ if (tx === undefined) {
143
+ throw new errors_js_2.EngineUnavailableError('lockForUpdate needs an open transaction(fn): a lock taken outside one is released at ' +
144
+ 'once and serialises nothing.');
145
+ }
146
+ assertUsable(tx);
147
+ const reg = this.registry.require(typeKey);
148
+ const where = (0, where_by_id_js_1.pkClause)(reg, id);
149
+ await tx.query(`SELECT 1 AS locked FROM ${this.qualified(reg)} WHERE ${where.sql} FOR UPDATE`, where.params);
150
+ }
151
+ /**
152
+ * Run one op's body in the right unit of work: the bound/ambient transaction when there is
153
+ * one, else a fresh `uow.run` (the original per-op transaction). A WRITE failure inside a
154
+ * shared transaction marks it rollback-only.
155
+ */
156
+ async inUnit(bound, write, body) {
157
+ const tx = bound ?? this.activeTx.getStore();
158
+ if (tx === undefined)
159
+ return this.uow.run(body);
160
+ assertUsable(tx);
161
+ try {
162
+ return await body(tx.query);
163
+ }
164
+ catch (err) {
165
+ if (write)
166
+ tx.failure ??= err;
167
+ throw err;
168
+ }
169
+ }
170
+ async runWrite(tx, op, typeKey, actor, opts) {
66
171
  const registration = this.registry.require(typeKey); // resolve-schema
67
172
  const nowMs = this.now();
68
- return this.uow.run(async (query) => {
173
+ return this.inUnit(tx, true, async (query) => {
69
174
  const ctx = (0, context_js_1.newWriteContext)({ op, actor, registration, query, nowMs }, {
70
175
  ...(opts.id !== undefined ? { id: opts.id } : {}),
71
176
  ...(opts.input !== undefined ? { input: opts.input } : {}),
@@ -90,32 +195,42 @@ class RecordEngineImpl {
90
195
  });
91
196
  }
92
197
  async create(typeKey, input, actor) {
93
- const id = await this.runWrite('create', typeKey, actor, { input });
198
+ return this.createIn(undefined, typeKey, input, actor);
199
+ }
200
+ async update(typeKey, id, input, actor) {
201
+ return this.updateIn(undefined, typeKey, id, input, actor);
202
+ }
203
+ async delete(typeKey, id, actor) {
204
+ return this.deleteIn(undefined, typeKey, id, actor);
205
+ }
206
+ async createIn(tx, typeKey, input, actor) {
207
+ const id = await this.runWrite(tx, 'create', typeKey, actor, { input });
94
208
  if (id === undefined)
95
209
  throw new errors_js_1.RegistrationError(`create '${typeKey}' produced no id`);
96
210
  return id;
97
211
  }
98
- async update(typeKey, id, input, actor) {
99
- await this.runWrite('update', typeKey, actor, { id, input });
212
+ async updateIn(tx, typeKey, id, input, actor) {
213
+ await this.runWrite(tx, 'update', typeKey, actor, { id, input });
100
214
  }
101
- async delete(typeKey, id, actor) {
102
- await this.runWrite('delete', typeKey, actor, { id });
215
+ async deleteIn(tx, typeKey, id, actor) {
216
+ await this.runWrite(tx, 'delete', typeKey, actor, { id });
103
217
  }
104
218
  // --- reads -----------------------------------------------------------------
105
- async gateRead(registration, actor, id, ownerId) {
219
+ async gateRead(registration, actor, id, record) {
106
220
  const action = { key: 'read' };
107
221
  const resource = {
108
222
  level: id !== undefined ? 'record' : 'object_type',
109
223
  typeKey: registration.typeKey,
110
- tenantId: actor.tenantId,
224
+ // Block D (#10): a fetched record is judged at ITS tenant; otherwise the actor's.
225
+ tenantId: record?.tenantId ?? actor.tenantId,
111
226
  ...(typeof id === 'string' ? { recordId: id } : {}),
112
227
  };
113
- // For an OWNED record read, pass the pre-fetched row owner so core's owner baseline
114
- // can compare it to the actor. Absent ⇒ no owner match ⇒ deny (missing row / other
115
- // tenant / non-owned type reads without it, as before).
228
+ // For a fetched record, every registered field + `ownerId` reach decide() (block D), so a
229
+ // field-conditioned rule fires on get() as it does in list(). No record (a missing row) ⇒
230
+ // no attributes ⇒ an owned type's owner baseline cannot match ⇒ deny, as before.
116
231
  const context = {
117
232
  isAuthenticated: true,
118
- ...(ownerId !== undefined ? { attributes: { ownerId } } : {}),
233
+ ...(record !== undefined ? { attributes: record.attributes } : {}),
119
234
  };
120
235
  let outcome;
121
236
  try {
@@ -129,7 +244,7 @@ class RecordEngineImpl {
129
244
  }
130
245
  }
131
246
  qualified(reg) {
132
- return reg.schema ? `\`${reg.schema}\`.\`${reg.table}\`` : `\`${reg.table}\``;
247
+ return (0, where_by_id_js_1.qualifiedTable)(reg);
133
248
  }
134
249
  /** Project a raw DB row → RecordView (field keys, not columns). */
135
250
  project(reg, row) {
@@ -152,29 +267,30 @@ class RecordEngineImpl {
152
267
  return { id, fields };
153
268
  }
154
269
  async get(typeKey, id, actor) {
270
+ return this.getIn(undefined, typeKey, id, actor);
271
+ }
272
+ async list(typeKey, query, actor) {
273
+ return this.listIn(undefined, typeKey, query, actor);
274
+ }
275
+ async getIn(tx, typeKey, id, actor) {
155
276
  const reg = this.registry.require(typeKey);
156
- return this.uow.run(async (query) => {
157
- const pk = reg.primaryKey;
158
- let where;
159
- let params;
160
- if (pk.kind === 'uuid') {
161
- if (typeof id !== 'string')
162
- throw new errors_js_1.RegistrationError(`'${typeKey}' needs a string id`);
163
- where = `\`${(0, registry_js_1.uuidPkColumn)(pk)}\` = ?`;
164
- params = [id];
165
- }
166
- else {
167
- if (typeof id === 'string')
168
- throw new errors_js_1.RegistrationError(`'${typeKey}' needs a key map`);
169
- where = pk.columns.map((c) => `\`${c}\` = ?`).join(' AND ');
170
- params = pk.columns.map((c) => id[c]);
171
- }
277
+ return this.inUnit(tx, false, async (query) => {
278
+ const pk = (0, where_by_id_js_1.pkClause)(reg, id);
279
+ let where = pk.sql;
280
+ let params = pk.params;
172
281
  // Tenant scoping: if the table has a tenant column, constrain to the actor's reachable
173
282
  // subtree (ADR-0027 — active node + descendants), NOT just the self-node. ROOT spans all
174
283
  // tenants — only a STRICT `=== true` isRoot counts (fail-closed). Fail-safe to the
175
284
  // self-node if the subtree is malformed/absent (non-widening).
285
+ // Review M6 (get ≡ list): exactly as in list(), when the record-level list scope is wired
286
+ // its fragment is the ONE tenant authority (it carries the own-subtree bound AND the
287
+ // cross-tenant grant reach), so the built-in clause is deferred to it.
288
+ // Resolve the list-scope fragment FIRST (a throw propagates before any SELECT). An EMPTY
289
+ // fragment never drops the tenant bound (review L1): the built-in clause then applies.
290
+ const fragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
291
+ const fragmentSql = fragment?.sql ? fragment : null;
176
292
  const tenantCol = this.tenantColumn(reg);
177
- if (tenantCol && actor.isRoot !== true) {
293
+ if (tenantCol && actor.isRoot !== true && !fragmentSql) {
178
294
  const nodes = this.tenantScopeNodes(actor);
179
295
  where += ` AND \`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`;
180
296
  params = [...params, ...nodes];
@@ -188,37 +304,39 @@ class RecordEngineImpl {
188
304
  where += ` AND ${scope.sql}`;
189
305
  params = [...params, ...scope.params];
190
306
  }
191
- if ((0, registry_js_1.isOwned)(reg)) {
192
- // OWNED read — FETCH-THEN-GATE. Core's owner baseline needs the row's owner,
193
- // which core (zero-dep) can't fetch. So we SELECT the row FIRST (tenant-scoped),
194
- // extract its owner, and gate on it. ⛔ THE FETCHED ROW IS NEVER SURFACED UNLESS
195
- // THE GATE ALLOWS: gateRead throws on deny BEFORE we project()/return, so a denied
196
- // read leaks nothing. A missing row ⇒ no owner ⇒ deny (never "null" that reveals
197
- // presence/absence differently from a real deny — both throw/deny before any row
198
- // data is returned).
199
- const rows = (await query(`SELECT * FROM ${this.qualified(reg)} WHERE ${where} LIMIT 1`, params));
200
- const first = rows[0];
201
- const ownerCol = (0, registry_js_1.ownerColumn)(reg);
202
- const owner = first && ownerCol ? first[ownerCol] : undefined;
203
- // gateRead THROWS AuthorizationDeniedError on deny → the row below is never reached.
204
- await this.gateRead(reg, actor, id, typeof owner === 'string' ? owner : undefined);
205
- // Allowed. Project the ALREADY-fetched row (no second query). A genuinely absent
206
- // row (owner undefined) would have been denied above by the missing owner match,
207
- // so reaching here with `!first` is not expected for owned types; guard anyway.
208
- return first ? this.project(reg, first) : null;
307
+ // Review M6: AND the SAME list-scope fragment list() uses, so get() reaches exactly the
308
+ // rows list() shows (incl. cross-tenant-granted ones) and hides what list() hides. A throw
309
+ // propagates (fail closed) before any SELECT runs.
310
+ if (fragmentSql) {
311
+ where += ` AND ${fragmentSql.sql}`;
312
+ params = [...params, ...fragmentSql.params];
209
313
  }
210
- // NON-owned / system type — gate BEFORE fetch (no owner to check; avoid an extra
211
- // query on the deny path). This is the original lean read path.
212
- await this.gateRead(reg, actor, id);
314
+ // FETCH-THEN-GATE for EVERY type (block D, #10). Core's decide() is zero-dep and cannot
315
+ // read the row, so the row is SELECTed first and its fields, owner and tenant are handed
316
+ // to the gate. ⛔ THE FETCHED ROW IS NEVER SURFACED UNLESS THE GATE ALLOWS: gateRead
317
+ // throws on deny before project(). The SAME row is projected (no second query).
213
318
  const rows = (await query(`SELECT * FROM ${this.qualified(reg)} WHERE ${where} LIMIT 1`, params));
214
319
  const first = rows[0];
215
- return first ? this.project(reg, first) : null;
320
+ if (first === undefined) {
321
+ // Missing — or outside the walled WHERE (foreign tenant/subtree, hidden by the list
322
+ // scope): indistinguishable by construction. Gate as before this change: record level,
323
+ // no attributes, actor tenant ⇒ an owned type denies (no owner match), any other type
324
+ // answers null. The caller learns nothing it did not learn before.
325
+ await this.gateRead(reg, actor, id);
326
+ return null;
327
+ }
328
+ const facts = (0, fetch_record_js_1.recordFacts)(reg, first);
329
+ await this.gateRead(reg, actor, id, {
330
+ attributes: (0, fetch_record_js_1.decisionAttributes)(reg, facts),
331
+ ...(facts.tenantId !== undefined ? { tenantId: facts.tenantId } : {}),
332
+ });
333
+ return this.project(reg, first);
216
334
  });
217
335
  }
218
- async list(typeKey, query, actor) {
336
+ async listIn(tx, typeKey, query, actor) {
219
337
  const reg = this.registry.require(typeKey);
220
338
  await this.gateRead(reg, actor);
221
- return this.uow.run(async (q) => {
339
+ return this.inUnit(tx, false, async (q) => {
222
340
  const clauses = [];
223
341
  const params = [];
224
342
  // Mandatory tenant scoping when the table is tenant-owned (cross-tenant safety).
@@ -239,8 +357,12 @@ class RecordEngineImpl {
239
357
  // So when `listScope` is present we DEFER the tenant bound entirely to the fragment — one
240
358
  // authority, no double-filter. (readScope system-types are unaffected: they have no
241
359
  // tenant column, so this clause never fired for them anyway; their wall is `readScope`.)
360
+ // The list-scope fragment is resolved up-front (a throw propagates — fail closed). An EMPTY
361
+ // fragment never drops the tenant bound (review L1): the built-in clause then applies.
362
+ const listFragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
363
+ const listFragmentSql = listFragment?.sql ? listFragment : null;
242
364
  const tenantCol = this.tenantColumn(reg);
243
- if (tenantCol && actor.isRoot !== true && !this.listScope) {
365
+ if (tenantCol && actor.isRoot !== true && !listFragmentSql) {
244
366
  const nodes = this.tenantScopeNodes(actor);
245
367
  clauses.push(`\`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`);
246
368
  params.push(...nodes);
@@ -289,28 +411,37 @@ class RecordEngineImpl {
289
411
  // (gateRead, at the TOP of list()) has allowed the read — the scope narrows rows WITHIN
290
412
  // an allowed object-type list; it NEVER substitutes for the gate (the root/data-plane
291
413
  // leak-guard). A scope-fn throw PROPAGATES (fail-closed — the read fails; we never fall
292
- // back to unscoped rows). An empty `sql` is simply not ANDed (no fabricated widening).
293
- if (this.listScope) {
294
- const scope = await this.listScope(reg, actor, 'read');
295
- if (scope.sql) {
296
- clauses.push(scope.sql);
297
- params.push(...scope.params);
298
- }
414
+ // back to unscoped rows). An empty `sql` is not ANDed; the built-in tenant clause above applies instead.
415
+ if (listFragmentSql) {
416
+ clauses.push(listFragmentSql.sql);
417
+ params.push(...listFragmentSql.params);
299
418
  }
300
419
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
301
420
  // Sort: resolve `sortBy` to a column the SAME whitelisted way as where-keys
302
421
  // (unknown key → throw). The direction is an enum-checked literal (only 'desc'
303
422
  // flips to DESC; anything else is ascending) — neither is ever raw-interpolated
304
423
  // user input.
305
- let orderBy = '';
424
+ //
425
+ // Review M1: the PK column(s) are ALWAYS appended as the final tiebreaker (`ORDER BY <pk>`
426
+ // alone when there is no sortBy), so the order is total and OFFSET paging never repeats or
427
+ // skips rows that tie on the sort column. PK columns come from `primaryKey` (often not a
428
+ // registered field); a PK column already used as the sort key is not repeated.
429
+ const orderTerms = [];
430
+ let sortColumn;
306
431
  if (query.sortBy !== undefined) {
307
432
  const field = reg.fields.find((f) => f.key === query.sortBy);
308
433
  if (!field) {
309
434
  throw new errors_js_1.RegistrationError(`list '${typeKey}': unknown sort field '${query.sortBy}'`);
310
435
  }
311
436
  const dir = query.sortOrder === 'desc' ? 'DESC' : 'ASC';
312
- orderBy = `ORDER BY \`${(0, registry_js_1.columnOf)(field)}\` ${dir}`;
437
+ sortColumn = (0, registry_js_1.columnOf)(field);
438
+ orderTerms.push(`\`${sortColumn}\` ${dir}`);
439
+ }
440
+ for (const col of (0, where_by_id_js_1.pkColumns)(reg)) {
441
+ if (col !== sortColumn)
442
+ orderTerms.push(`\`${col}\` ASC`);
313
443
  }
444
+ const orderBy = `ORDER BY ${orderTerms.join(', ')}`;
314
445
  const limit = Math.min(Math.max(query.limit ?? 100, 1), 1000);
315
446
  const offset = Math.max(query.offset ?? 0, 0);
316
447
  const rows = (await q(`SELECT * FROM ${this.qualified(reg)} ${where} ${orderBy} LIMIT ? OFFSET ?`, [...params, limit, offset]));