@astram/engine 0.5.3 → 0.7.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 (37) hide show
  1. package/dist/adapters/mariadb-unit-of-work.d.ts +8 -0
  2. package/dist/adapters/mariadb-unit-of-work.d.ts.map +1 -1
  3. package/dist/adapters/mariadb-unit-of-work.js +1 -0
  4. package/dist/decorators/object-type.decorator.d.ts +10 -0
  5. package/dist/decorators/object-type.decorator.d.ts.map +1 -1
  6. package/dist/decorators/object-type.decorator.js +5 -0
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +2 -1
  10. package/dist/pipe/context.d.ts +8 -0
  11. package/dist/pipe/context.d.ts.map +1 -1
  12. package/dist/pipe/filter-sql.d.ts +66 -0
  13. package/dist/pipe/filter-sql.d.ts.map +1 -0
  14. package/dist/pipe/filter-sql.js +266 -0
  15. package/dist/pipe/record-engine.d.ts +34 -1
  16. package/dist/pipe/record-engine.d.ts.map +1 -1
  17. package/dist/pipe/record-engine.js +256 -87
  18. package/dist/pipe/records-service.d.ts +19 -1
  19. package/dist/pipe/records-service.d.ts.map +1 -1
  20. package/dist/pipe/records-service.js +32 -0
  21. package/dist/pipe/stages/audit.d.ts.map +1 -1
  22. package/dist/pipe/stages/audit.js +8 -1
  23. package/dist/pipe/stages/fetch-record.d.ts +11 -1
  24. package/dist/pipe/stages/fetch-record.d.ts.map +1 -1
  25. package/dist/pipe/stages/fetch-record.js +18 -9
  26. package/dist/pipe/stages/permission-gate.d.ts +8 -0
  27. package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
  28. package/dist/pipe/stages/permission-gate.js +15 -9
  29. package/dist/pipe/stages/validate.d.ts +6 -0
  30. package/dist/pipe/stages/validate.d.ts.map +1 -1
  31. package/dist/pipe/stages/validate.js +22 -8
  32. package/dist/registry.d.ts +13 -0
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +66 -0
  35. package/dist/types.d.ts +112 -2
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +2 -2
@@ -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 clauses = [];
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, registry_js_1.columnOf)(field);
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 = Math.min(Math.max(query.limit ?? 100, 1), 1000);
450
- const offset = Math.max(query.offset ?? 0, 0);
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,EACX,MAAM,aAAa,CAAC;AAGrB,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AACD,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;;;GAMG;AACH,qBACa,cAAc;IAEA,OAAO,CAAC,QAAQ,CAAC,MAAM;IAC7B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAGrC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;gBAJO,MAAM,EAAE,YAAY,EAC1B,GAAG,EAAE;QAAE,SAAS,CAAC,EAAE,SAAS,CAAA;KAAE,EAG/C,eAAe,CAAC,EAAE,sBAAsB,YAAA;IAWrD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAG1D,qFAAqF;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjE;;;;;;OAMG;IACG,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAKtE;;;;OAIG;IACH,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY;IAKzC,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,qBAAqB;CAS9B;AAED;;;GAGG;AACH,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS;IAEnC,4FAA4F;IAC5F,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAGhE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAGpD,+EAA+E;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CAG5D"}
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,CA2BlD"}
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"}
@@ -33,7 +33,14 @@ function auditStage(audit) {
33
33
  tenantId: ctx.actor.tenantId,
34
34
  action,
35
35
  resource,
36
- attributes: { op: ctx.op, typeKey: ctx.registration.typeKey },
36
+ attributes: {
37
+ op: ctx.op,
38
+ typeKey: ctx.registration.typeKey,
39
+ // createMany rows only: single-row writes keep exactly { op, typeKey }.
40
+ ...(ctx.batch !== undefined
41
+ ? { batchId: ctx.batch.id, batchIndex: ctx.batch.index }
42
+ : {}),
43
+ },
37
44
  };
38
45
  // Write the audit row on the pipe's OWN transaction (atomic with the data).
39
46
  await audit.record(event, ctx.query);
@@ -30,7 +30,8 @@
30
30
  * - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
31
31
  */
32
32
  import { AuthorizationDeniedError } from '../../errors.js';
33
- import type { ObjectType } from '../../types.js';
33
+ import type { QueryFn } from '../../ports.js';
34
+ import type { ObjectType, Principal, RecordId } from '../../types.js';
34
35
  import type { Stage } from '../context.js';
35
36
  /** The decision-relevant facts of one fetched row. */
36
37
  export interface RecordFacts {
@@ -52,5 +53,14 @@ export declare function recordFacts(reg: ObjectType, row: Record<string, unknown
52
53
  export declare function decisionAttributes(reg: ObjectType, facts: Pick<RecordFacts, 'fields' | 'ownerId'>): Record<string, unknown>;
53
54
  /** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
54
55
  export declare function recordNotReachable(reg: ObjectType): AuthorizationDeniedError;
56
+ /**
57
+ * Read one record's decision facts through `whereById` (pk + actor tenant + ADR-0030 write
58
+ * scope), `LIMIT 1`, with `FOR UPDATE` when `lock` is set. No row (missing ≡ foreign) ⇒
59
+ * `recordNotReachable`. Shared by `fetchRecordStage` (lock) and `RecordEngine.authorize`
60
+ * (lock only inside a transaction). The row itself is never returned, only its facts.
61
+ */
62
+ export declare function fetchRecordFacts(reg: ObjectType, actor: Principal, id: RecordId, query: QueryFn, opts: {
63
+ readonly lock: boolean;
64
+ }): Promise<RecordFacts>;
55
65
  export declare function fetchRecordStage(): Stage;
56
66
  //# sourceMappingURL=fetch-record.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-record.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/fetch-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,wBAAwB,EAAqB,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAGzD,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,CAetF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,QAAQ,GAAG,SAAS,CAAC,GAC7C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB;AAED,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,GAAG,wBAAwB,CAK5E;AAED,wBAAgB,gBAAgB,IAAI,KAAK,CAwBxC"}
1
+ {"version":3,"file":"fetch-record.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/fetch-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,wBAAwB,EAAqB,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAGzD,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,CAetF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,QAAQ,GAAG,SAAS,CAAC,GAC7C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB;AAED,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,GAAG,wBAAwB,CAK5E;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,SAAS,EAChB,EAAE,EAAE,QAAQ,EACZ,KAAK,EAAE,OAAO,EACd,IAAI,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC/B,OAAO,CAAC,WAAW,CAAC,CAStB;AAED,wBAAgB,gBAAgB,IAAI,KAAK,CAexC"}