@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.
- package/dist/decorators/access-bundle.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.d.ts +2 -0
- package/dist/decorators/object-type.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.js +3 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/pipe/context.d.ts +17 -6
- package/dist/pipe/context.d.ts.map +1 -1
- package/dist/pipe/record-engine.d.ts +37 -5
- package/dist/pipe/record-engine.d.ts.map +1 -1
- package/dist/pipe/record-engine.js +206 -75
- package/dist/pipe/records-service.d.ts +18 -1
- package/dist/pipe/records-service.d.ts.map +1 -1
- package/dist/pipe/records-service.js +42 -6
- package/dist/pipe/stages/audit.d.ts.map +1 -1
- package/dist/pipe/stages/derive-write-context.d.ts.map +1 -1
- package/dist/pipe/stages/derive-write-context.js +6 -0
- package/dist/pipe/stages/fetch-record.d.ts +54 -0
- package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
- package/dist/pipe/stages/fetch-record.js +101 -0
- package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
- package/dist/pipe/stages/permission-gate.js +17 -9
- package/dist/pipe/stages/write.d.ts.map +1 -1
- package/dist/pipe/stages/write.js +75 -38
- package/dist/pipe/where-by-id.d.ts +33 -0
- package/dist/pipe/where-by-id.d.ts.map +1 -0
- package/dist/pipe/where-by-id.js +69 -0
- package/dist/registry.d.ts +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +70 -0
- package/dist/types.d.ts +66 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/dist/pipe/stages/fetch-owner.d.ts +0 -28
- package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
- package/dist/pipe/stages/fetch-owner.js +0 -90
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/pipe/context.d.ts
CHANGED
|
@@ -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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
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
|
|
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 →
|
|
7
|
-
* Phase 1) → write → junction-sync → audit (MANDATORY) →
|
|
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 (
|
|
13
|
-
* (
|
|
14
|
-
*
|
|
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
|
|
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 →
|
|
8
|
-
* Phase 1) → write → junction-sync → audit (MANDATORY) →
|
|
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 (
|
|
14
|
-
* (
|
|
15
|
-
*
|
|
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
|
|
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-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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
|
-
...(
|
|
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
|
|
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.
|
|
157
|
-
const pk = reg
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 && !
|
|
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
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]));
|