@astram/engine 0.6.0 → 0.8.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/adapters/mariadb-audit-sink.d.ts +10 -1
- package/dist/adapters/mariadb-audit-sink.d.ts.map +1 -1
- package/dist/adapters/mariadb-audit-sink.js +8 -1
- package/dist/decorators/object-type.decorator.d.ts +10 -0
- package/dist/decorators/object-type.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.js +5 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/module/engine.module.d.ts +8 -0
- package/dist/module/engine.module.d.ts.map +1 -1
- package/dist/module/engine.module.js +7 -4
- package/dist/pipe/context.d.ts +8 -0
- package/dist/pipe/context.d.ts.map +1 -1
- package/dist/pipe/filter-sql.d.ts +66 -0
- package/dist/pipe/filter-sql.d.ts.map +1 -0
- package/dist/pipe/filter-sql.js +266 -0
- package/dist/pipe/record-engine.d.ts +34 -1
- package/dist/pipe/record-engine.d.ts.map +1 -1
- package/dist/pipe/record-engine.js +256 -87
- package/dist/pipe/records-service.d.ts +19 -1
- package/dist/pipe/records-service.d.ts.map +1 -1
- package/dist/pipe/records-service.js +32 -0
- package/dist/pipe/stages/audit.d.ts.map +1 -1
- package/dist/pipe/stages/audit.js +8 -1
- package/dist/pipe/stages/fetch-record.d.ts +11 -1
- package/dist/pipe/stages/fetch-record.d.ts.map +1 -1
- package/dist/pipe/stages/fetch-record.js +18 -9
- package/dist/pipe/stages/permission-gate.d.ts +8 -0
- package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
- package/dist/pipe/stages/permission-gate.js +15 -9
- package/dist/pipe/stages/validate.d.ts +6 -0
- package/dist/pipe/stages/validate.d.ts.map +1 -1
- package/dist/pipe/stages/validate.js +22 -8
- package/dist/registry.d.ts +38 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +128 -2
- package/dist/types.d.ts +133 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/package.json +2 -2
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { TableRegistry } from '../registry.js';
|
|
19
19
|
import type { AuditPort, DecisionPort, UnitOfWork, QueryFn } from '../ports.js';
|
|
20
|
-
import type { ListQuery, Principal, RecordEngine, RecordId, RecordInput, RecordPage, RecordView, ObjectType } from '../types.js';
|
|
20
|
+
import type { AggregateQuery, AggregateResult, ListQuery, Principal, RecordEngine, RecordId, RecordInput, RecordPage, RecordView, ObjectType } from '../types.js';
|
|
21
21
|
export interface RecordEngineDeps {
|
|
22
22
|
readonly registry: TableRegistry;
|
|
23
23
|
readonly uow: UnitOfWork;
|
|
@@ -98,6 +98,14 @@ export declare class RecordEngineImpl implements RecordEngine {
|
|
|
98
98
|
*/
|
|
99
99
|
lockForUpdate(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
|
|
100
100
|
private lockIn;
|
|
101
|
+
/** Record-level decision for one declared action, no write (see `RecordEngine.authorize`). */
|
|
102
|
+
authorize(typeKey: string, id: RecordId, action: string, actor: Principal): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* The write path's gates without the write (SPEC §5 E): declared action → type pre-gate (no
|
|
105
|
+
* SQL) → `whereById` fetch → record-level decide on the row's facts and tenant. Read-only unit,
|
|
106
|
+
* so a deny never makes an open transaction rollback-only. No audit, no list scope.
|
|
107
|
+
*/
|
|
108
|
+
private authorizeIn;
|
|
101
109
|
/**
|
|
102
110
|
* Run one op's body in the right unit of work: the bound/ambient transaction when there is
|
|
103
111
|
* one, else a fresh `uow.run` (the original per-op transaction). A WRITE failure inside a
|
|
@@ -108,7 +116,16 @@ export declare class RecordEngineImpl implements RecordEngine {
|
|
|
108
116
|
create(typeKey: string, input: RecordInput, actor: Principal): Promise<RecordId>;
|
|
109
117
|
update(typeKey: string, id: RecordId, input: RecordInput, actor: Principal): Promise<void>;
|
|
110
118
|
delete(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
|
|
119
|
+
createMany(typeKey: string, inputs: readonly RecordInput[], actor: Principal): Promise<RecordId[]>;
|
|
111
120
|
private createIn;
|
|
121
|
+
/**
|
|
122
|
+
* Bulk = single (Shield #7, SPEC v2 §5.G): every row runs the unchanged single-row create pipe,
|
|
123
|
+
* in order, in ONE unit of work (joins an outer transaction). No batched INSERT, no decision
|
|
124
|
+
* shared across rows. The first failing row throws and rollback-only state discards the whole
|
|
125
|
+
* batch, its audit rows included. Inputs are checked before the type lookup and before any
|
|
126
|
+
* transaction opens, so a bad call neither touches the DB nor poisons an outer transaction.
|
|
127
|
+
*/
|
|
128
|
+
private createManyIn;
|
|
112
129
|
private updateIn;
|
|
113
130
|
private deleteIn;
|
|
114
131
|
private gateRead;
|
|
@@ -118,7 +135,23 @@ export declare class RecordEngineImpl implements RecordEngine {
|
|
|
118
135
|
get(typeKey: string, id: RecordId, actor: Principal): Promise<RecordView | null>;
|
|
119
136
|
list(typeKey: string, query: ListQuery, actor: Principal): Promise<RecordPage>;
|
|
120
137
|
private getIn;
|
|
138
|
+
/**
|
|
139
|
+
* The ONE read WHERE of a list (SPEC v2 §5.D): list-scope fragment resolved first, tenant
|
|
140
|
+
* clause, readScope wall, equality `where`, search, list-scope fragment. Shared by the data
|
|
141
|
+
* SELECT and the COUNT (and later `aggregate`), so they cannot diverge. Async because it
|
|
142
|
+
* awaits the list-scope seam; takes no `q` (no DB read of its own). Callers run `gateRead`
|
|
143
|
+
* first.
|
|
144
|
+
*/
|
|
145
|
+
private buildReadWhere;
|
|
121
146
|
private listIn;
|
|
147
|
+
aggregate(typeKey: string, query: AggregateQuery, actor: Principal): Promise<AggregateResult>;
|
|
148
|
+
/**
|
|
149
|
+
* Aggregate over exactly the rows `list()` returns (SPEC v2 §5.F, Shield #8): the SAME gate
|
|
150
|
+
* (object-type `read`, first) and the SAME `buildReadWhere`, then ONE
|
|
151
|
+
* `SELECT … FROM <table> <where> [GROUP BY … ORDER BY …] LIMIT ?`. No table alias, so a
|
|
152
|
+
* list-scope fragment's bare columns resolve. Read-only: no audit row, no write.
|
|
153
|
+
*/
|
|
154
|
+
private aggregateIn;
|
|
122
155
|
/** The physical column of a `tenantId`-trusted field, if the table has one. */
|
|
123
156
|
private tenantColumn;
|
|
124
157
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-engine.d.ts","sourceRoot":"","sources":["../../src/pipe/record-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;
|
|
1
|
+
{"version":3,"file":"record-engine.d.ts","sourceRoot":"","sources":["../../src/pipe/record-engine.ts"],"names":[],"mappings":"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"}
|
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
exports.RecordEngineImpl = void 0;
|
|
21
21
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
22
|
+
const node_crypto_1 = require("node:crypto");
|
|
22
23
|
const errors_js_1 = require("../errors.js");
|
|
23
24
|
const registry_js_1 = require("../registry.js");
|
|
24
25
|
const types_js_1 = require("../types.js");
|
|
25
26
|
const context_js_1 = require("./context.js");
|
|
26
27
|
const fetch_record_js_1 = require("./stages/fetch-record.js");
|
|
27
28
|
const where_by_id_js_1 = require("./where-by-id.js");
|
|
29
|
+
const filter_sql_js_1 = require("./filter-sql.js");
|
|
28
30
|
const permission_gate_js_1 = require("./stages/permission-gate.js");
|
|
29
31
|
const derive_write_context_js_1 = require("./stages/derive-write-context.js");
|
|
30
32
|
const validate_js_1 = require("./stages/validate.js");
|
|
@@ -32,6 +34,8 @@ const write_js_1 = require("./stages/write.js");
|
|
|
32
34
|
const junction_sync_js_1 = require("./stages/junction-sync.js");
|
|
33
35
|
const audit_js_1 = require("./stages/audit.js");
|
|
34
36
|
const errors_js_2 = require("../errors.js");
|
|
37
|
+
/** `createMany` row cap (list-query-operators SPEC v2 §10). */
|
|
38
|
+
const CREATE_MANY_CAP = 500;
|
|
35
39
|
/** Fail closed when a transaction handle outlives its transaction or is already doomed. */
|
|
36
40
|
function assertUsable(tx) {
|
|
37
41
|
if (!tx.open) {
|
|
@@ -132,6 +136,9 @@ class RecordEngineImpl {
|
|
|
132
136
|
list: (typeKey, query, actor) => this.listIn(state, typeKey, query, actor),
|
|
133
137
|
transaction: (fn) => this.transactionIn(state, fn),
|
|
134
138
|
lockForUpdate: (typeKey, id, actor) => this.lockIn(state, typeKey, id, actor),
|
|
139
|
+
authorize: (typeKey, id, action, actor) => this.authorizeIn(state, typeKey, id, action, actor),
|
|
140
|
+
aggregate: (typeKey, query, actor) => this.aggregateIn(state, typeKey, query, actor),
|
|
141
|
+
createMany: (typeKey, inputs, actor) => this.createManyIn(state, typeKey, inputs, actor),
|
|
135
142
|
};
|
|
136
143
|
}
|
|
137
144
|
/**
|
|
@@ -152,6 +159,33 @@ class RecordEngineImpl {
|
|
|
152
159
|
const where = (0, where_by_id_js_1.pkClause)(reg, id);
|
|
153
160
|
await tx.query(`SELECT 1 AS locked FROM ${this.qualified(reg)} WHERE ${where.sql} FOR UPDATE`, where.params);
|
|
154
161
|
}
|
|
162
|
+
/** Record-level decision for one declared action, no write (see `RecordEngine.authorize`). */
|
|
163
|
+
async authorize(typeKey, id, action, actor) {
|
|
164
|
+
return this.authorizeIn(undefined, typeKey, id, action, actor);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The write path's gates without the write (SPEC §5 E): declared action → type pre-gate (no
|
|
168
|
+
* SQL) → `whereById` fetch → record-level decide on the row's facts and tenant. Read-only unit,
|
|
169
|
+
* so a deny never makes an open transaction rollback-only. No audit, no list scope.
|
|
170
|
+
*/
|
|
171
|
+
async authorizeIn(bound, typeKey, id, action, actor) {
|
|
172
|
+
const reg = this.registry.require(typeKey);
|
|
173
|
+
if (!(0, registry_js_1.effectiveActions)(reg).includes(action)) {
|
|
174
|
+
throw new errors_js_1.RegistrationError(`action '${action}' is not declared on type '${typeKey}'`);
|
|
175
|
+
}
|
|
176
|
+
const label = `authorize ${action} ${typeKey}`;
|
|
177
|
+
await (0, permission_gate_js_1.decideOrThrow)(this.decision, actor, { key: action }, { level: 'object_type', typeKey, tenantId: actor.tenantId }, { isAuthenticated: true }, label);
|
|
178
|
+
const lock = (bound ?? this.activeTx.getStore()) !== undefined;
|
|
179
|
+
await this.inUnit(bound, false, async (query) => {
|
|
180
|
+
const facts = await (0, fetch_record_js_1.fetchRecordFacts)(reg, actor, id, query, { lock });
|
|
181
|
+
await (0, permission_gate_js_1.decideOrThrow)(this.decision, actor, { key: action }, {
|
|
182
|
+
level: 'record',
|
|
183
|
+
typeKey,
|
|
184
|
+
tenantId: facts.tenantId ?? actor.tenantId,
|
|
185
|
+
...(typeof id === 'string' ? { recordId: id } : {}),
|
|
186
|
+
}, { isAuthenticated: true, attributes: (0, fetch_record_js_1.decisionAttributes)(reg, facts) }, label);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
155
189
|
/**
|
|
156
190
|
* Run one op's body in the right unit of work: the bound/ambient transaction when there is
|
|
157
191
|
* one, else a fresh `uow.run` (the original per-op transaction). A WRITE failure inside a
|
|
@@ -173,11 +207,17 @@ class RecordEngineImpl {
|
|
|
173
207
|
}
|
|
174
208
|
async runWrite(tx, op, typeKey, actor, opts) {
|
|
175
209
|
const registration = this.registry.require(typeKey); // resolve-schema
|
|
210
|
+
// R2 (ADR-0038): an authority-only type is never CRUD'd through the engine — fail closed
|
|
211
|
+
// before any SQL or decision.
|
|
212
|
+
if (registration.crudActions === false) {
|
|
213
|
+
throw new errors_js_1.RegistrationError(`type '${typeKey}' declares crudActions:false; engine ${op} refused`);
|
|
214
|
+
}
|
|
176
215
|
const nowMs = this.now();
|
|
177
216
|
return this.inUnit(tx, true, async (query) => {
|
|
178
217
|
const ctx = (0, context_js_1.newWriteContext)({ op, actor, registration, query, nowMs }, {
|
|
179
218
|
...(opts.id !== undefined ? { id: opts.id } : {}),
|
|
180
219
|
...(opts.input !== undefined ? { input: opts.input } : {}),
|
|
220
|
+
...(opts.batch !== undefined ? { batch: opts.batch } : {}),
|
|
181
221
|
junctions: { ...(opts.input?.junctions ?? {}) },
|
|
182
222
|
});
|
|
183
223
|
for (const stage of this.writeStages) {
|
|
@@ -207,12 +247,46 @@ class RecordEngineImpl {
|
|
|
207
247
|
async delete(typeKey, id, actor) {
|
|
208
248
|
return this.deleteIn(undefined, typeKey, id, actor);
|
|
209
249
|
}
|
|
250
|
+
async createMany(typeKey, inputs, actor) {
|
|
251
|
+
return this.createManyIn(undefined, typeKey, inputs, actor);
|
|
252
|
+
}
|
|
210
253
|
async createIn(tx, typeKey, input, actor) {
|
|
211
254
|
const id = await this.runWrite(tx, 'create', typeKey, actor, { input });
|
|
212
255
|
if (id === undefined)
|
|
213
256
|
throw new errors_js_1.RegistrationError(`create '${typeKey}' produced no id`);
|
|
214
257
|
return id;
|
|
215
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Bulk = single (Shield #7, SPEC v2 §5.G): every row runs the unchanged single-row create pipe,
|
|
261
|
+
* in order, in ONE unit of work (joins an outer transaction). No batched INSERT, no decision
|
|
262
|
+
* shared across rows. The first failing row throws and rollback-only state discards the whole
|
|
263
|
+
* batch, its audit rows included. Inputs are checked before the type lookup and before any
|
|
264
|
+
* transaction opens, so a bad call neither touches the DB nor poisons an outer transaction.
|
|
265
|
+
*/
|
|
266
|
+
async createManyIn(tx, typeKey, inputs, actor) {
|
|
267
|
+
if (!Array.isArray(inputs))
|
|
268
|
+
throw new errors_js_2.ValidationError('inputs', 'must be an array');
|
|
269
|
+
if (inputs.length === 0)
|
|
270
|
+
throw new errors_js_2.ValidationError('inputs', 'must not be empty');
|
|
271
|
+
if (inputs.length > CREATE_MANY_CAP) {
|
|
272
|
+
throw new errors_js_2.ValidationError('inputs', `at most ${CREATE_MANY_CAP} rows per call`);
|
|
273
|
+
}
|
|
274
|
+
const batchId = (0, node_crypto_1.randomUUID)();
|
|
275
|
+
return this.transactionIn(tx, async () => {
|
|
276
|
+
const state = tx ?? this.activeTx.getStore();
|
|
277
|
+
const ids = [];
|
|
278
|
+
for (const [index, input] of inputs.entries()) {
|
|
279
|
+
const id = await this.runWrite(state, 'create', typeKey, actor, {
|
|
280
|
+
input,
|
|
281
|
+
batch: { id: batchId, index },
|
|
282
|
+
});
|
|
283
|
+
if (id === undefined)
|
|
284
|
+
throw new errors_js_1.RegistrationError(`create '${typeKey}' produced no id`);
|
|
285
|
+
ids.push(id);
|
|
286
|
+
}
|
|
287
|
+
return ids;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
216
290
|
async updateIn(tx, typeKey, id, input, actor) {
|
|
217
291
|
await this.runWrite(tx, 'update', typeKey, actor, { id, input });
|
|
218
292
|
}
|
|
@@ -337,90 +411,98 @@ class RecordEngineImpl {
|
|
|
337
411
|
return this.project(reg, first);
|
|
338
412
|
});
|
|
339
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* The ONE read WHERE of a list (SPEC v2 §5.D): list-scope fragment resolved first, tenant
|
|
416
|
+
* clause, readScope wall, equality `where`, search, list-scope fragment. Shared by the data
|
|
417
|
+
* SELECT and the COUNT (and later `aggregate`), so they cannot diverge. Async because it
|
|
418
|
+
* awaits the list-scope seam; takes no `q` (no DB read of its own). Callers run `gateRead`
|
|
419
|
+
* first.
|
|
420
|
+
*/
|
|
421
|
+
async buildReadWhere(reg, query, actor) {
|
|
422
|
+
const typeKey = reg.typeKey;
|
|
423
|
+
const clauses = [];
|
|
424
|
+
const params = [];
|
|
425
|
+
// Mandatory tenant scoping when the table is tenant-owned (cross-tenant safety).
|
|
426
|
+
// ROOT spans all tenants — but only a STRICT `=== true` isRoot counts (fail-closed):
|
|
427
|
+
// a truthy-non-boolean must never bypass the tenant boundary.
|
|
428
|
+
// SUBTREE-AWARE (ADR-0027): a non-root actor is bounded to its reachable subtree
|
|
429
|
+
// (active node + descendants), NOT just the self-node — `tenant_col IN (?, ...)` over
|
|
430
|
+
// `actor.scopeSubtreeNodeIds` (the SAME cached set the tenant gate + list-scope use).
|
|
431
|
+
// Fail-safe to `[actor.tenantId]` (self-node) if the subtree is malformed/absent.
|
|
432
|
+
//
|
|
433
|
+
// ⛔ ONE-AUTHORITY (Invariant #3, §10bis.5): this built-in clause is the FAIL-SAFE for
|
|
434
|
+
// when the list-scope engine is NOT wired (pre-Phase-3 / a consumer without it). When
|
|
435
|
+
// `listScope` IS wired, the list-scope FRAGMENT is the single source of the tenant
|
|
436
|
+
// boundary — it already emits `tenant_col = ?` / `IN (subtree)` for non-root AND ORs in
|
|
437
|
+
// the cross-tenant grant reach (§10bis.5). Emitting BOTH here would AND this narrower
|
|
438
|
+
// own-subtree filter over the fragment and KILL every cross-tenant-granted row before the
|
|
439
|
+
// fragment's widening can include it (the exact divergence the list≡decide gate caught).
|
|
440
|
+
// So when `listScope` is present we DEFER the tenant bound entirely to the fragment — one
|
|
441
|
+
// authority, no double-filter. (readScope system-types are unaffected: they have no
|
|
442
|
+
// tenant column, so this clause never fired for them anyway; their wall is `readScope`.)
|
|
443
|
+
// The list-scope fragment is resolved up-front (a throw propagates — fail closed). An EMPTY
|
|
444
|
+
// fragment never drops the tenant bound (review L1): the built-in clause then applies.
|
|
445
|
+
const listFragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
|
|
446
|
+
const listFragmentSql = listFragment?.sql ? listFragment : null;
|
|
447
|
+
const tenantCol = this.tenantColumn(reg);
|
|
448
|
+
if (tenantCol && actor.isRoot !== true && !listFragmentSql) {
|
|
449
|
+
const nodes = this.tenantScopeNodes(actor);
|
|
450
|
+
clauses.push(`\`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`);
|
|
451
|
+
params.push(...nodes);
|
|
452
|
+
}
|
|
453
|
+
// Read-scope wall (ADR-0027, Task 4.1): a type declaring `readScope` (the identity
|
|
454
|
+
// `system` types with no tenant column) has its NON-ROOT reads bounded to the caller's
|
|
455
|
+
// reachable subtree. ANDed AFTER the tenant clause (which won't fire for these system
|
|
456
|
+
// types) into BOTH the data SELECT and the COUNT (shared clauses/params ⇒ consistent
|
|
457
|
+
// by construction). Root gets NO clause (spans all). Injection-free — only `?`-bound
|
|
458
|
+
// node ids; identifiers are the type's own column or a fixed framework template.
|
|
459
|
+
const readScope = this.readScopeClause(reg, actor);
|
|
460
|
+
if (readScope) {
|
|
461
|
+
clauses.push(readScope.sql);
|
|
462
|
+
params.push(...readScope.params);
|
|
463
|
+
}
|
|
464
|
+
// `where` filters (SPEC v2 §5.A): scalar equality or one §10 operator per field. The
|
|
465
|
+
// COLUMN comes from a whitelisted field-key lookup; every VALUE is coerced and a bound
|
|
466
|
+
// `?` param — never interpolated. Malformed/unknown ⇒ throw (never "no filter").
|
|
467
|
+
const filters = (0, filter_sql_js_1.buildFilterClauses)(reg, query.where, typeKey);
|
|
468
|
+
clauses.push(...filters.clauses);
|
|
469
|
+
params.push(...filters.params);
|
|
470
|
+
// Free-text search: OR a `LIKE ?` across the named registered fields. Field
|
|
471
|
+
// keys are whitelisted (unknown → throw); the term is a bound `?` param with
|
|
472
|
+
// LIKE wildcards escaped, so a literal `%`/`_` in the term is not a wildcard.
|
|
473
|
+
const search = query.search;
|
|
474
|
+
if (search && search.fields.length > 0) {
|
|
475
|
+
// A non-string term is a bad operand (SPEC §13): ValidationError, never a raw TypeError.
|
|
476
|
+
if (typeof search.term !== 'string')
|
|
477
|
+
throw new errors_js_2.ValidationError('search', 'term must be a string');
|
|
478
|
+
const pattern = `%${escapeLikeTerm(search.term)}%`;
|
|
479
|
+
const ors = [];
|
|
480
|
+
for (const key of search.fields) {
|
|
481
|
+
const { column } = (0, filter_sql_js_1.resolveColumn)(reg, key, typeKey, 'search');
|
|
482
|
+
ors.push(`\`${column}\` LIKE ? ESCAPE '\\\\'`);
|
|
483
|
+
params.push(pattern);
|
|
484
|
+
}
|
|
485
|
+
clauses.push(`(${ors.join(' OR ')})`);
|
|
486
|
+
}
|
|
487
|
+
// Record-level list scope (§8.3): AND the framework-computed visible-set fragment into
|
|
488
|
+
// BOTH the data SELECT and the COUNT (via the shared clauses/params — so `total` and
|
|
489
|
+
// `rows` stay consistent BY CONSTRUCTION). Runs only AFTER the coarse object-type gate
|
|
490
|
+
// (gateRead, at the TOP of list()) has allowed the read — the scope narrows rows WITHIN
|
|
491
|
+
// an allowed object-type list; it NEVER substitutes for the gate (the root/data-plane
|
|
492
|
+
// leak-guard). A scope-fn throw PROPAGATES (fail-closed — the read fails; we never fall
|
|
493
|
+
// back to unscoped rows). An empty `sql` is not ANDed; the built-in tenant clause above applies instead.
|
|
494
|
+
if (listFragmentSql) {
|
|
495
|
+
clauses.push(listFragmentSql.sql);
|
|
496
|
+
params.push(...listFragmentSql.params);
|
|
497
|
+
}
|
|
498
|
+
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
499
|
+
return { where, params };
|
|
500
|
+
}
|
|
340
501
|
async listIn(tx, typeKey, query, actor) {
|
|
341
502
|
const reg = this.registry.require(typeKey);
|
|
342
503
|
await this.gateRead(reg, actor);
|
|
343
504
|
return this.inUnit(tx, false, async (q) => {
|
|
344
|
-
const
|
|
345
|
-
const params = [];
|
|
346
|
-
// Mandatory tenant scoping when the table is tenant-owned (cross-tenant safety).
|
|
347
|
-
// ROOT spans all tenants — but only a STRICT `=== true` isRoot counts (fail-closed):
|
|
348
|
-
// a truthy-non-boolean must never bypass the tenant boundary.
|
|
349
|
-
// SUBTREE-AWARE (ADR-0027): a non-root actor is bounded to its reachable subtree
|
|
350
|
-
// (active node + descendants), NOT just the self-node — `tenant_col IN (?, ...)` over
|
|
351
|
-
// `actor.scopeSubtreeNodeIds` (the SAME cached set the tenant gate + list-scope use).
|
|
352
|
-
// Fail-safe to `[actor.tenantId]` (self-node) if the subtree is malformed/absent.
|
|
353
|
-
//
|
|
354
|
-
// ⛔ ONE-AUTHORITY (Invariant #3, §10bis.5): this built-in clause is the FAIL-SAFE for
|
|
355
|
-
// when the list-scope engine is NOT wired (pre-Phase-3 / a consumer without it). When
|
|
356
|
-
// `listScope` IS wired, the list-scope FRAGMENT is the single source of the tenant
|
|
357
|
-
// boundary — it already emits `tenant_col = ?` / `IN (subtree)` for non-root AND ORs in
|
|
358
|
-
// the cross-tenant grant reach (§10bis.5). Emitting BOTH here would AND this narrower
|
|
359
|
-
// own-subtree filter over the fragment and KILL every cross-tenant-granted row before the
|
|
360
|
-
// fragment's widening can include it (the exact divergence the list≡decide gate caught).
|
|
361
|
-
// So when `listScope` is present we DEFER the tenant bound entirely to the fragment — one
|
|
362
|
-
// authority, no double-filter. (readScope system-types are unaffected: they have no
|
|
363
|
-
// tenant column, so this clause never fired for them anyway; their wall is `readScope`.)
|
|
364
|
-
// The list-scope fragment is resolved up-front (a throw propagates — fail closed). An EMPTY
|
|
365
|
-
// fragment never drops the tenant bound (review L1): the built-in clause then applies.
|
|
366
|
-
const listFragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
|
|
367
|
-
const listFragmentSql = listFragment?.sql ? listFragment : null;
|
|
368
|
-
const tenantCol = this.tenantColumn(reg);
|
|
369
|
-
if (tenantCol && actor.isRoot !== true && !listFragmentSql) {
|
|
370
|
-
const nodes = this.tenantScopeNodes(actor);
|
|
371
|
-
clauses.push(`\`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`);
|
|
372
|
-
params.push(...nodes);
|
|
373
|
-
}
|
|
374
|
-
// Read-scope wall (ADR-0027, Task 4.1): a type declaring `readScope` (the identity
|
|
375
|
-
// `system` types with no tenant column) has its NON-ROOT reads bounded to the caller's
|
|
376
|
-
// reachable subtree. ANDed AFTER the tenant clause (which won't fire for these system
|
|
377
|
-
// types) into BOTH the data SELECT and the COUNT (shared clauses/params ⇒ consistent
|
|
378
|
-
// by construction). Root gets NO clause (spans all). Injection-free — only `?`-bound
|
|
379
|
-
// node ids; identifiers are the type's own column or a fixed framework template.
|
|
380
|
-
const readScope = this.readScopeClause(reg, actor);
|
|
381
|
-
if (readScope) {
|
|
382
|
-
clauses.push(readScope.sql);
|
|
383
|
-
params.push(...readScope.params);
|
|
384
|
-
}
|
|
385
|
-
// Equality filters on registered field columns only (unknown keys rejected).
|
|
386
|
-
// The COLUMN comes from a whitelisted field-key lookup; the VALUE is a bound
|
|
387
|
-
// `?` param — never interpolated.
|
|
388
|
-
for (const [k, v] of Object.entries(query.where ?? {})) {
|
|
389
|
-
const field = reg.fields.find((f) => f.key === k);
|
|
390
|
-
if (!field)
|
|
391
|
-
throw new errors_js_1.RegistrationError(`list '${typeKey}': unknown filter field '${k}'`);
|
|
392
|
-
clauses.push(`\`${(0, registry_js_1.columnOf)(field)}\` = ?`);
|
|
393
|
-
params.push(v);
|
|
394
|
-
}
|
|
395
|
-
// Free-text search: OR a `LIKE ?` across the named registered fields. Field
|
|
396
|
-
// keys are whitelisted (unknown → throw); the term is a bound `?` param with
|
|
397
|
-
// LIKE wildcards escaped, so a literal `%`/`_` in the term is not a wildcard.
|
|
398
|
-
const search = query.search;
|
|
399
|
-
if (search && search.fields.length > 0) {
|
|
400
|
-
const pattern = `%${escapeLikeTerm(search.term)}%`;
|
|
401
|
-
const ors = [];
|
|
402
|
-
for (const key of search.fields) {
|
|
403
|
-
const field = reg.fields.find((f) => f.key === key);
|
|
404
|
-
if (!field) {
|
|
405
|
-
throw new errors_js_1.RegistrationError(`list '${typeKey}': unknown search field '${key}'`);
|
|
406
|
-
}
|
|
407
|
-
ors.push(`\`${(0, registry_js_1.columnOf)(field)}\` LIKE ? ESCAPE '\\\\'`);
|
|
408
|
-
params.push(pattern);
|
|
409
|
-
}
|
|
410
|
-
clauses.push(`(${ors.join(' OR ')})`);
|
|
411
|
-
}
|
|
412
|
-
// Record-level list scope (§8.3): AND the framework-computed visible-set fragment into
|
|
413
|
-
// BOTH the data SELECT and the COUNT (via the shared clauses/params — so `total` and
|
|
414
|
-
// `rows` stay consistent BY CONSTRUCTION). Runs only AFTER the coarse object-type gate
|
|
415
|
-
// (gateRead, at the TOP of list()) has allowed the read — the scope narrows rows WITHIN
|
|
416
|
-
// an allowed object-type list; it NEVER substitutes for the gate (the root/data-plane
|
|
417
|
-
// leak-guard). A scope-fn throw PROPAGATES (fail-closed — the read fails; we never fall
|
|
418
|
-
// back to unscoped rows). An empty `sql` is not ANDed; the built-in tenant clause above applies instead.
|
|
419
|
-
if (listFragmentSql) {
|
|
420
|
-
clauses.push(listFragmentSql.sql);
|
|
421
|
-
params.push(...listFragmentSql.params);
|
|
422
|
-
}
|
|
423
|
-
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
505
|
+
const { where, params } = await this.buildReadWhere(reg, query, actor);
|
|
424
506
|
// Sort: resolve `sortBy` to a column the SAME whitelisted way as where-keys
|
|
425
507
|
// (unknown key → throw). The direction is an enum-checked literal (only 'desc'
|
|
426
508
|
// flips to DESC; anything else is ascending) — neither is ever raw-interpolated
|
|
@@ -433,12 +515,8 @@ class RecordEngineImpl {
|
|
|
433
515
|
const orderTerms = [];
|
|
434
516
|
let sortColumn;
|
|
435
517
|
if (query.sortBy !== undefined) {
|
|
436
|
-
const field = reg.fields.find((f) => f.key === query.sortBy);
|
|
437
|
-
if (!field) {
|
|
438
|
-
throw new errors_js_1.RegistrationError(`list '${typeKey}': unknown sort field '${query.sortBy}'`);
|
|
439
|
-
}
|
|
440
518
|
const dir = query.sortOrder === 'desc' ? 'DESC' : 'ASC';
|
|
441
|
-
sortColumn = (0,
|
|
519
|
+
sortColumn = (0, filter_sql_js_1.resolveColumn)(reg, query.sortBy, typeKey, 'sort').column;
|
|
442
520
|
orderTerms.push(`\`${sortColumn}\` ${dir}`);
|
|
443
521
|
}
|
|
444
522
|
for (const col of (0, where_by_id_js_1.pkColumns)(reg)) {
|
|
@@ -446,14 +524,69 @@ class RecordEngineImpl {
|
|
|
446
524
|
orderTerms.push(`\`${col}\` ASC`);
|
|
447
525
|
}
|
|
448
526
|
const orderBy = `ORDER BY ${orderTerms.join(', ')}`;
|
|
449
|
-
const limit =
|
|
450
|
-
const offset = Math.max(query.offset
|
|
527
|
+
const limit = clampLimit(query.limit);
|
|
528
|
+
const offset = Number.isFinite(query.offset) ? Math.max(query.offset, 0) : 0;
|
|
451
529
|
const rows = (await q(`SELECT * FROM ${this.qualified(reg)} ${where} ${orderBy} LIMIT ? OFFSET ?`, [...params, limit, offset]));
|
|
452
530
|
const countRows = (await q(`SELECT COUNT(*) AS n FROM ${this.qualified(reg)} ${where}`, params));
|
|
453
531
|
const total = Number(countRows[0]?.n ?? 0);
|
|
454
532
|
return { rows: rows.map((r) => this.project(reg, r)), total };
|
|
455
533
|
});
|
|
456
534
|
}
|
|
535
|
+
async aggregate(typeKey, query, actor) {
|
|
536
|
+
return this.aggregateIn(undefined, typeKey, query, actor);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Aggregate over exactly the rows `list()` returns (SPEC v2 §5.F, Shield #8): the SAME gate
|
|
540
|
+
* (object-type `read`, first) and the SAME `buildReadWhere`, then ONE
|
|
541
|
+
* `SELECT … FROM <table> <where> [GROUP BY … ORDER BY …] LIMIT ?`. No table alias, so a
|
|
542
|
+
* list-scope fragment's bare columns resolve. Read-only: no audit row, no write.
|
|
543
|
+
*/
|
|
544
|
+
async aggregateIn(tx, typeKey, query, actor) {
|
|
545
|
+
const reg = this.registry.require(typeKey);
|
|
546
|
+
await this.gateRead(reg, actor);
|
|
547
|
+
const agg = (0, filter_sql_js_1.buildAggregateSelect)(reg, query.groupBy, query.measures, typeKey);
|
|
548
|
+
return this.inUnit(tx, false, async (q) => {
|
|
549
|
+
const { where, params } = await this.buildReadWhere(reg, query, actor);
|
|
550
|
+
const limit = clampLimit(query.limit);
|
|
551
|
+
const sql = [
|
|
552
|
+
`SELECT ${agg.select} FROM ${this.qualified(reg)}`,
|
|
553
|
+
where,
|
|
554
|
+
agg.groupBy ? `GROUP BY ${agg.groupBy} ORDER BY ${agg.orderBy}` : '',
|
|
555
|
+
'LIMIT ?',
|
|
556
|
+
]
|
|
557
|
+
.filter((part) => part !== '')
|
|
558
|
+
.join(' ');
|
|
559
|
+
const rows = (await q(sql, [...params, limit]));
|
|
560
|
+
const grouped = agg.groupBy !== '';
|
|
561
|
+
// Ungrouped SQL always yields one row; if none came back, shape the empty aggregate.
|
|
562
|
+
const source = rows.length > 0 || grouped ? rows : [{}];
|
|
563
|
+
const groups = source.map((row) => {
|
|
564
|
+
const key = {};
|
|
565
|
+
for (const [alias, fieldKey] of Object.entries(agg.keyMap)) {
|
|
566
|
+
key[fieldKey] = ownValue(row, alias) ?? null;
|
|
567
|
+
}
|
|
568
|
+
const values = {};
|
|
569
|
+
for (const col of agg.columns) {
|
|
570
|
+
// Own property only: an alias such as `constructor` must not read Object.prototype.
|
|
571
|
+
const raw = ownValue(row, col.alias);
|
|
572
|
+
if (raw === undefined || raw === null) {
|
|
573
|
+
values[col.alias] = col.fn === 'count' ? 0 : null;
|
|
574
|
+
}
|
|
575
|
+
else if (col.numeric || typeof raw === 'bigint') {
|
|
576
|
+
values[col.alias] = toSafeNumber(raw, typeKey, col.alias);
|
|
577
|
+
}
|
|
578
|
+
else if (typeof raw === 'string' || typeof raw === 'number' || raw instanceof Date) {
|
|
579
|
+
values[col.alias] = raw;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
throw new errors_js_2.EngineUnavailableError(`aggregate '${typeKey}': unexpected driver value for '${col.alias}'`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return { key, values };
|
|
586
|
+
});
|
|
587
|
+
return { groups };
|
|
588
|
+
});
|
|
589
|
+
}
|
|
457
590
|
/** The physical column of a `tenantId`-trusted field, if the table has one. */
|
|
458
591
|
tenantColumn(reg) {
|
|
459
592
|
const f = reg.fields.find((x) => x.trusted === 'tenantId');
|
|
@@ -510,6 +643,19 @@ class RecordEngineImpl {
|
|
|
510
643
|
}
|
|
511
644
|
}
|
|
512
645
|
exports.RecordEngineImpl = RecordEngineImpl;
|
|
646
|
+
/**
|
|
647
|
+
* Clamp a list/aggregate `limit` to 1..1000. Absent or non-finite (`NaN`, ±Infinity) ⇒ the
|
|
648
|
+
* default 100, so a bad number is never bound as `NULL`.
|
|
649
|
+
*/
|
|
650
|
+
function clampLimit(limit) {
|
|
651
|
+
if (!Number.isFinite(limit))
|
|
652
|
+
return 100;
|
|
653
|
+
return Math.min(Math.max(limit, 1), 1000);
|
|
654
|
+
}
|
|
655
|
+
/** A row's own property value (undefined when absent; never an inherited prototype member). */
|
|
656
|
+
function ownValue(row, key) {
|
|
657
|
+
return Object.prototype.hasOwnProperty.call(row, key) ? row[key] : undefined;
|
|
658
|
+
}
|
|
513
659
|
/**
|
|
514
660
|
* Escape the SQL-LIKE meta-characters (`\`, `%`, `_`) in a user-supplied search
|
|
515
661
|
* term so they match literally. The engine's LIKE clause pairs this with
|
|
@@ -520,3 +666,26 @@ exports.RecordEngineImpl = RecordEngineImpl;
|
|
|
520
666
|
function escapeLikeTerm(term) {
|
|
521
667
|
return term.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
522
668
|
}
|
|
669
|
+
/**
|
|
670
|
+
* Convert a COUNT/SUM/AVG (or numeric MIN/MAX) driver value — BigInt, DECIMAL string or number —
|
|
671
|
+
* to a JS `number` (SPEC v2 §11). An integer outside the safe range, or a non-numeric value,
|
|
672
|
+
* throws `EngineUnavailableError` rather than return a silently wrong number.
|
|
673
|
+
*/
|
|
674
|
+
function toSafeNumber(raw, typeKey, alias) {
|
|
675
|
+
const fail = () => {
|
|
676
|
+
throw new errors_js_2.EngineUnavailableError(`aggregate '${typeKey}': '${alias}' cannot be represented as a safe JS number`);
|
|
677
|
+
};
|
|
678
|
+
if (typeof raw === 'bigint') {
|
|
679
|
+
if (raw > BigInt(Number.MAX_SAFE_INTEGER) || raw < BigInt(Number.MIN_SAFE_INTEGER))
|
|
680
|
+
fail();
|
|
681
|
+
return Number(raw);
|
|
682
|
+
}
|
|
683
|
+
if (typeof raw !== 'number' && typeof raw !== 'string')
|
|
684
|
+
return fail();
|
|
685
|
+
const n = typeof raw === 'number' ? raw : raw.trim() === '' ? NaN : Number(raw);
|
|
686
|
+
if (!Number.isFinite(n))
|
|
687
|
+
fail();
|
|
688
|
+
if (Number.isInteger(n) && !Number.isSafeInteger(n))
|
|
689
|
+
fail();
|
|
690
|
+
return n;
|
|
691
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Principal } from '@astram/core';
|
|
2
|
-
import type { RecordEngine, RecordInput, RecordId, RecordView, ListQuery, RecordPage } from '../types.js';
|
|
2
|
+
import type { RecordEngine, RecordInput, RecordId, RecordView, ListQuery, RecordPage, AggregateQuery, AggregateResult } from '../types.js';
|
|
3
3
|
/** Round-2 port: resolves a service-account KEY to a seeded principal. */
|
|
4
4
|
export interface ServiceAccountResolver {
|
|
5
5
|
resolve(key: string): Principal;
|
|
@@ -26,6 +26,12 @@ export declare class RecordsService {
|
|
|
26
26
|
delete(typeKey: string, id: RecordId): Promise<void>;
|
|
27
27
|
/** Row lock inside the open `transaction(fn)` (see `RecordEngine.lockForUpdate`). */
|
|
28
28
|
lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
|
|
29
|
+
/** Record-level decision for a declared action, no write (see `RecordEngine.authorize`). */
|
|
30
|
+
authorize(typeKey: string, id: RecordId, action: string): Promise<void>;
|
|
31
|
+
/** Aggregate over the rows `list()` returns (see `RecordEngine.aggregate`). */
|
|
32
|
+
aggregate(typeKey: string, query: AggregateQuery): Promise<AggregateResult>;
|
|
33
|
+
/** Create 1..500 records all-or-nothing (see `RecordEngine.createMany`). */
|
|
34
|
+
createMany(typeKey: string, inputs: readonly RecordInput[]): Promise<RecordId[]>;
|
|
29
35
|
/**
|
|
30
36
|
* Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
|
|
31
37
|
* principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
|
|
@@ -40,6 +46,12 @@ export declare class RecordsService {
|
|
|
40
46
|
* silent default. A string key requires a ServiceAccountResolver (Round 2).
|
|
41
47
|
*/
|
|
42
48
|
as(who: string | Principal): BoundRecords;
|
|
49
|
+
/**
|
|
50
|
+
* The request principal every op on this service acts as (the guard-resolved `req.principal`).
|
|
51
|
+
* For callers that must stamp or audit with exactly the engine's identity (e.g. a storage key
|
|
52
|
+
* tenant prefix). Fail-closed: no principal ⇒ throws `EngineUnavailableError`, like every op.
|
|
53
|
+
*/
|
|
54
|
+
requestPrincipal(): Readonly<Principal>;
|
|
43
55
|
private actor;
|
|
44
56
|
private resolveServiceAccount;
|
|
45
57
|
}
|
|
@@ -60,5 +72,11 @@ export declare class BoundRecords {
|
|
|
60
72
|
delete(typeKey: string, id: RecordId): Promise<void>;
|
|
61
73
|
/** Row lock inside the open transaction (see `RecordEngine.lockForUpdate`). */
|
|
62
74
|
lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
|
|
75
|
+
/** Record-level decision for a declared action, no write (see `RecordEngine.authorize`). */
|
|
76
|
+
authorize(typeKey: string, id: RecordId, action: string): Promise<void>;
|
|
77
|
+
/** Aggregate over the rows `list()` returns (see `RecordEngine.aggregate`). */
|
|
78
|
+
aggregate(typeKey: string, query: AggregateQuery): Promise<AggregateResult>;
|
|
79
|
+
/** Create 1..500 records all-or-nothing (see `RecordEngine.createMany`). */
|
|
80
|
+
createMany(typeKey: string, inputs: readonly RecordInput[]): Promise<RecordId[]>;
|
|
63
81
|
}
|
|
64
82
|
//# sourceMappingURL=records-service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EACX,QAAQ,EACR,UAAU,EACV,SAAS,EACT,UAAU,
|
|
1
|
+
{"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -61,6 +61,18 @@ let RecordsService = class RecordsService {
|
|
|
61
61
|
async lockForUpdate(typeKey, id) {
|
|
62
62
|
return this.engine.lockForUpdate(typeKey, id, this.actor());
|
|
63
63
|
}
|
|
64
|
+
/** Record-level decision for a declared action, no write (see `RecordEngine.authorize`). */
|
|
65
|
+
async authorize(typeKey, id, action) {
|
|
66
|
+
return this.engine.authorize(typeKey, id, action, this.actor());
|
|
67
|
+
}
|
|
68
|
+
/** Aggregate over the rows `list()` returns (see `RecordEngine.aggregate`). */
|
|
69
|
+
async aggregate(typeKey, query) {
|
|
70
|
+
return this.engine.aggregate(typeKey, query, this.actor());
|
|
71
|
+
}
|
|
72
|
+
/** Create 1..500 records all-or-nothing (see `RecordEngine.createMany`). */
|
|
73
|
+
async createMany(typeKey, inputs) {
|
|
74
|
+
return this.engine.createMany(typeKey, inputs, this.actor());
|
|
75
|
+
}
|
|
64
76
|
/**
|
|
65
77
|
* Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
|
|
66
78
|
* principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
|
|
@@ -81,6 +93,14 @@ let RecordsService = class RecordsService {
|
|
|
81
93
|
const actor = typeof who === 'string' ? this.resolveServiceAccount(who) : who;
|
|
82
94
|
return new BoundRecords(this.engine, actor);
|
|
83
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* The request principal every op on this service acts as (the guard-resolved `req.principal`).
|
|
98
|
+
* For callers that must stamp or audit with exactly the engine's identity (e.g. a storage key
|
|
99
|
+
* tenant prefix). Fail-closed: no principal ⇒ throws `EngineUnavailableError`, like every op.
|
|
100
|
+
*/
|
|
101
|
+
requestPrincipal() {
|
|
102
|
+
return Object.freeze({ ...this.actor() });
|
|
103
|
+
}
|
|
84
104
|
actor() {
|
|
85
105
|
const p = this.req?.principal;
|
|
86
106
|
if (!p) {
|
|
@@ -140,5 +160,17 @@ class BoundRecords {
|
|
|
140
160
|
lockForUpdate(typeKey, id) {
|
|
141
161
|
return this.engine.lockForUpdate(typeKey, id, this.actor);
|
|
142
162
|
}
|
|
163
|
+
/** Record-level decision for a declared action, no write (see `RecordEngine.authorize`). */
|
|
164
|
+
authorize(typeKey, id, action) {
|
|
165
|
+
return this.engine.authorize(typeKey, id, action, this.actor);
|
|
166
|
+
}
|
|
167
|
+
/** Aggregate over the rows `list()` returns (see `RecordEngine.aggregate`). */
|
|
168
|
+
aggregate(typeKey, query) {
|
|
169
|
+
return this.engine.aggregate(typeKey, query, this.actor);
|
|
170
|
+
}
|
|
171
|
+
/** Create 1..500 records all-or-nothing (see `RecordEngine.createMany`). */
|
|
172
|
+
createMany(typeKey, inputs) {
|
|
173
|
+
return this.engine.createMany(typeKey, inputs, this.actor);
|
|
174
|
+
}
|
|
143
175
|
}
|
|
144
176
|
exports.BoundRecords = BoundRecords;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,
|
|
1
|
+
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAkClD"}
|