@jarenjs/db 0.73.0 → 0.83.2

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 (53) hide show
  1. package/ARCHITECTURE.md +70 -7
  2. package/README.md +69 -6
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +52 -13
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +163 -15
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/REPLICATION-FORMAT.md +19 -13
  10. package/docs/SEARCH.md +55 -0
  11. package/package.json +8 -4
  12. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  13. package/schemas/jaren-migration.schema.json +49 -0
  14. package/schemas/jaren-model.authoring.schema.json +360 -0
  15. package/schemas/jaren-model.draft-07.schema.json +128 -0
  16. package/schemas/jaren-model.schema.json +128 -0
  17. package/src/algebra.js +26 -4
  18. package/src/backup.js +12 -7
  19. package/src/cursor.js +27 -4
  20. package/src/dag-job.js +2 -1
  21. package/src/ddl.js +13 -0
  22. package/src/derive.js +14 -3
  23. package/src/dialect.js +12 -0
  24. package/src/dialects/check-read.js +151 -0
  25. package/src/dialects/invariant-sql.js +117 -0
  26. package/src/dialects/postgres.js +28 -4
  27. package/src/dialects/sqlite.js +23 -3
  28. package/src/driver.js +1 -0
  29. package/src/drivers/bun.js +22 -4
  30. package/src/emit.js +133 -25
  31. package/src/entity.js +98 -41
  32. package/src/errors.js +8 -0
  33. package/src/graph.js +8 -1
  34. package/src/index.js +3 -0
  35. package/src/introspect.js +81 -12
  36. package/src/invariants.js +45 -0
  37. package/src/jobs.js +39 -6
  38. package/src/live-nested.js +27 -10
  39. package/src/live.js +51 -136
  40. package/src/migrate.js +136 -22
  41. package/src/model.js +12 -0
  42. package/src/mutation.js +165 -0
  43. package/src/physical.js +147 -0
  44. package/src/plan.js +275 -64
  45. package/src/query.js +175 -78
  46. package/src/search.js +144 -0
  47. package/src/sql.js +60 -0
  48. package/src/store.js +49 -13
  49. package/src/tracker.js +63 -39
  50. package/src/window.js +1 -0
  51. package/types/index.d.ts +59 -4
  52. package/types/search.d.ts +20 -0
  53. package/types/typed.d.ts +1 -0
package/src/errors.js CHANGED
@@ -46,6 +46,7 @@ export const DB_CODES = Object.freeze({
46
46
  JD0035: 'the continuation does not belong to this ordering',
47
47
  JD0036: 'a snapshot page needs an immutable ordering',
48
48
  JD0037: 'strictStreaming refused a plan that buffers',
49
+ JD0038: 'the native mutation document is unsupported or invalid',
49
50
  JD0040: 'the save spans a relation cycle',
50
51
  JD0050: 'live queries require change capture',
51
52
  JD0051: 'the demanded live mode is unavailable',
@@ -109,6 +110,8 @@ export const DB_CODES = Object.freeze({
109
110
  JD2092: 'a worker transport bound was exceeded',
110
111
  JD2093: 'the worker protocol frame is invalid',
111
112
  JD2094: 'the durable snapshot failed and the connection is invalid',
113
+ JD2095: 'the trusted SQL or synchronous transaction authority was refused',
114
+ JD2096: 'a persistence invariant rejected the mutation',
112
115
  });
113
116
 
114
117
  /**
@@ -554,6 +557,11 @@ export function wrapDriverError(error, details = {}) {
554
557
  generic.retryable = false;
555
558
  return generic;
556
559
  }
560
+ if (typeof error?.message === 'string' && error.message.includes('jaren invariant:')) {
561
+ const wrapped = new DbRuntimeError('JD2096', error.message, { ...details, cause: error });
562
+ wrapped.class = 'constraint'; wrapped.retryable = false;
563
+ return wrapped;
564
+ }
557
565
  const classified = classifyDriverError(error, details.unique);
558
566
  const message = error?.message ?? String(error);
559
567
  /** @type {any} */
package/src/graph.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * whole one.
15
15
  */
16
16
 
17
+ import { columnCodec } from './physical.js';
18
+
17
19
  import { DbRuntimeError } from './errors.js';
18
20
  import { utf8Length } from './cursor.js';
19
21
  import { jsonBytes, decodeCountedJson } from './json-bytes.js';
@@ -73,9 +75,14 @@ function checkBounds(node, include, keyed, raw, sizes) {
73
75
  * @returns {any}
74
76
  */
75
77
  export function mergeEntityRow(entityMapping, row, docField = 'doc') {
76
- const doc = JSON.parse(row[docField]);
78
+ const doc = entityMapping.document === false ? {} : JSON.parse(row[docField]);
77
79
  for (const column of entityMapping.columns) {
78
80
  if (column.source === 'epoch(document)') continue;
81
+ if (entityMapping.document === false && column.codec) {
82
+ const value = columnCodec(column).decode(row[column.physical ?? column.name]);
83
+ if (value !== undefined) Object.defineProperty(doc, column.name, { value, enumerable: true, writable: true, configurable: true });
84
+ continue;
85
+ }
79
86
  const value = row[column.name];
80
87
  if (value === null || value === undefined) continue;
81
88
  doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
package/src/index.js CHANGED
@@ -100,3 +100,6 @@ export {
100
100
  export { createDagJobRunner, RUN_IDENTITY_NODE } from './dag-job.js';
101
101
  export { REPLICATION_VERSION, REPLICATION_DEFAULTS, normalizeFrontier,
102
102
  normalizeReplication, normalizeReplicationSnapshot, encodeReplication, replicationIdentity } from './replication-format.js';
103
+
104
+ export { planInvariants } from './ddl.js';
105
+ export { planPhysicalMigration } from './migrate.js';
package/src/introspect.js CHANGED
@@ -73,7 +73,7 @@ function loss(code, object, detail) {
73
73
  * @param {string} table
74
74
  * @returns {any} value-or-promise
75
75
  */
76
- function readTable(connection, table) {
76
+ function readTable(connection, table, objects) {
77
77
  const dialect = connection.dialect;
78
78
  const all = (sql) => chain(connection.prepare(sql), (statement) => statement.all([]));
79
79
  return chain(all(dialect.introspect.columns(table)), (columnRows) =>
@@ -95,30 +95,46 @@ function readTable(connection, table) {
95
95
  (rows) => withColumns(i + 1, [...out, {
96
96
  name: String(declared[i].name),
97
97
  unique: Number(declared[i].uniq) !== 0,
98
- columns: rows.map((row) => String(row.name)),
98
+ partial: Number(declared[i].partial ?? 0) !== 0,
99
+ columns: rows.map((row) => row.name == null ? null : String(row.name)),
100
+ sql: objects.find((o) => o.type === 'index' && o.name === String(declared[i].name))?.sql ?? null,
99
101
  }]));
100
102
  };
101
103
  return chain(withColumns(0, []), (indexes) =>
102
104
  chain(keyIndex === undefined
103
105
  ? []
104
- : all(dialect.introspect.indexColumns(String(keyIndex.name))), (keyRows) => ({
106
+ : all(dialect.introspect.indexColumns(String(keyIndex.name))), (keyRows) =>
107
+ chain(dialect.introspect.checks === undefined ? []
108
+ : all(dialect.introspect.checks(table)), (checkRows) => ({
105
109
  name: table,
106
- primaryKey: keyRows.map((row) => String(row.name)),
110
+ sql: objects.find((o) => o.type === 'table' && o.name === table)?.sql ?? null,
111
+ primaryKey: columnRows.some((row) => Number(row.pk) > 0)
112
+ ? columnRows.filter((row) => Number(row.pk) > 0)
113
+ .sort((a, b) => Number(a.pk) - Number(b.pk)).map((row) => String(row.name))
114
+ : keyRows.map((row) => String(row.name)),
107
115
  columns: columnRows.map((row) => ({
108
116
  name: String(row.name),
109
117
  type: String(row.type),
110
118
  generated: Number(row.hidden) !== 0,
119
+ primaryKeyOrdinal: Number(row.pk ?? 0),
120
+ nullable: Number(row.not_null ?? 0) === 0,
121
+ default: row.default_value ?? null,
111
122
  })),
112
123
  generated: dialect.readGenerated(generatedRows),
113
124
  indexes,
125
+ checks: dialect.readChecks?.(checkRows) ?? [],
114
126
  foreignKeys: fkRows.map((row) => ({
115
127
  column: String(row.source_column),
116
128
  target: String(row.target),
117
129
  targetColumn: row.target_column === null || row.target_column === undefined
118
130
  ? null : String(row.target_column),
119
131
  onDelete: String(row.on_delete ?? 'NO ACTION').toUpperCase(),
132
+ onUpdate: String(row.on_update ?? 'NO ACTION').toUpperCase(),
133
+ group: row.id ?? null,
134
+ ordinal: Number(row.seq ?? 0),
135
+ match: String(row.match ?? 'NONE'),
120
136
  })),
121
- })));
137
+ }))));
122
138
  }))));
123
139
  }
124
140
 
@@ -149,10 +165,23 @@ export function readSchema(connection, options = undefined) {
149
165
  if (String(row.type) === 'view') views.push(name);
150
166
  else names.push(name);
151
167
  }
152
- const step = (i, out) => (i >= names.length
153
- ? { tables: out, views }
154
- : chain(readTable(connection, names[i]), (table) => step(i + 1, [...out, table])));
155
- return step(0, []);
168
+ const readObjects = dialect.introspect.objects === undefined ? []
169
+ : chain(connection.prepare(dialect.introspect.objects()), (s) => s.all([]));
170
+ return chain(readObjects, (catalog) => {
171
+ const complete = [...catalog];
172
+ for (const row of rows) {
173
+ if (!complete.some((o) => o.type === row.type && o.name === row.name))
174
+ complete.push({ ...row, owner: row.name, sql: null });
175
+ }
176
+ const objects = complete.filter((row) => !engine.has(String(row.owner))
177
+ && (wanted === null || wanted.has(String(row.owner)) || wanted.has(String(row.name))))
178
+ .map((row) => ({ type: String(row.type), name: String(row.name),
179
+ owner: String(row.owner), sql: row.sql ?? null }));
180
+ const step = (i, out) => (i >= names.length
181
+ ? { tables: out, views, objects }
182
+ : chain(readTable(connection, names[i], objects), (table) => step(i + 1, [...out, table])));
183
+ return step(0, []);
184
+ });
156
185
  }));
157
186
  }
158
187
 
@@ -229,6 +258,11 @@ function deriveCollection(dialect, table, report, keys, functionNames) {
229
258
 
230
259
  const indexes = [];
231
260
  for (const index of table.indexes) {
261
+ if (index.partial || index.columns.some((column) => column === null)) {
262
+ report(loss('unmapped-index', `${table.name}.${index.name}`,
263
+ 'a partial predicate or expression term cannot be represented by an unconditional member index'));
264
+ continue;
265
+ }
232
266
  if (index.columns.length === 1 && computed.has(index.columns[0])) {
233
267
  const declared = { name: indexName(table.name, index.name),
234
268
  expression: computed.get(index.columns[0]) };
@@ -416,9 +450,10 @@ function deriveEntity(dialect, table, report) {
416
450
  const uniqueColumns = new Set();
417
451
  const indexedColumns = new Set();
418
452
  for (const index of table.indexes) {
419
- if (index.columns.length !== 1) {
453
+ if (index.partial || index.columns.some((column) => column === null)
454
+ || index.columns.length !== 1) {
420
455
  report(loss('unmapped-index', `${table.name}.${index.name}`,
421
- 'a composite index over an entity is not a property-level declaration'));
456
+ 'a partial, expression or composite entity index is not a property-level declaration'));
422
457
  continue;
423
458
  }
424
459
  (index.unique ? uniqueColumns : indexedColumns).add(index.columns[0]);
@@ -461,6 +496,26 @@ function deriveEntity(dialect, table, report) {
461
496
  if (Object.keys(entity).length > 0) property['x-entity'] = entity;
462
497
  properties[column.name] = property;
463
498
  }
499
+ for (const check of table.checks) {
500
+ const property = properties[check.column];
501
+ const family = property?.type === 'integer' ? 'number'
502
+ : property?.type === 'string' ? 'string' : property?.type;
503
+ if (check.values === undefined || property === undefined
504
+ || check.values.some((value) => typeof value !== family
505
+ || (property.type === 'integer' && !Number.isInteger(value)))) {
506
+ report(loss('unmapped-constraint', `${table.name}.${check.name}`,
507
+ 'the CHECK is not a complete scalar enum of the mapped column type'));
508
+ continue;
509
+ }
510
+ // Several enum CHECKs constrain the same column by intersection.
511
+ const values = property.enum === undefined ? check.values
512
+ : property.enum.filter((value) => check.values.includes(value));
513
+ if (values.length === 0) {
514
+ report(loss('unmapped-constraint', `${table.name}.${check.name}`,
515
+ 'the enum CHECKs have an empty intersection, which a non-empty schema enum cannot declare'));
516
+ }
517
+ else property.enum = values;
518
+ }
464
519
  report(loss('document-members', table.name,
465
520
  'an entity\'s document column holds every property the mapping did not give a column, '
466
521
  + 'and those are not in the physical shape'));
@@ -517,6 +572,11 @@ export function introspectModel(connection, options = undefined) {
517
572
  add(loss('unmapped-view', view,
518
573
  'a view is not a shape a model document can declare'));
519
574
  }
575
+ for (const object of schema.objects) {
576
+ if (object.type === 'trigger') add({ ...loss('unmapped-object', object.name,
577
+ 'an application-owned trigger program must be explicitly preserved; inspection grants no drop permission'),
578
+ sql: object.sql, owner: object.owner });
579
+ }
520
580
 
521
581
  const joinTables = new Set(schema.tables
522
582
  .filter((table) => looksLikeJoinTable(dialect, table))
@@ -530,6 +590,8 @@ export function introspectModel(connection, options = undefined) {
530
590
  if (joinTables.has(table.name)) continue;
531
591
  if (looksLikeCollection(dialect, table)) {
532
592
  collections[table.name] = deriveCollection(dialect, table, add, options?.keys, byName);
593
+ for (const check of table.checks) add(loss('unmapped-constraint', `${table.name}.${check.name}`,
594
+ 'a CHECK over physical collection columns does not constrain the document schema'));
533
595
  continue;
534
596
  }
535
597
  const hasDocument = table.columns.some((column) => column.name === DOC_COLUMN);
@@ -578,6 +640,13 @@ export function introspectModel(connection, options = undefined) {
578
640
  + `thing(s) the model would — ${report.map((row) => `${row.code} (${row.object})`)
579
641
  .join(', ')}`);
580
642
  }
581
- return { model, report: Object.freeze(report.map((row) => Object.freeze(row))) };
643
+ const inventory = schema.objects.map((object) => Object.freeze({ ...object,
644
+ disposition: object.type === 'trigger' || object.type === 'view'
645
+ || report.some((row) => row.object === object.name
646
+ || row.object === `${object.owner}.${object.name}`
647
+ || (row.code === 'unmapped-table' && row.object === object.owner))
648
+ ? 'preserve' : 'derived' }));
649
+ return { model, report: Object.freeze(report.map((row) => Object.freeze(row))),
650
+ inventory: Object.freeze(inventory) };
582
651
  });
583
652
  }
@@ -0,0 +1,45 @@
1
+ //@ts-check
2
+ /** Persistence rules compile through the existing query evaluator. SQL lowering
3
+ * accepts a bounded scalar subset and reports its writer population explicitly. */
4
+ import { compileJsonQuery } from '@jarenjs/json/query';
5
+ import { DbCompileError, DbRuntimeError } from './errors.js';
6
+
7
+ /** @param {any} rules @param {string} path @returns {any[]} */
8
+ export function normalizeInvariants(rules, path) {
9
+ if (rules === undefined) return [];
10
+ if (!Array.isArray(rules)) throw new DbCompileError('JD0005', 'invariants must be an array', path);
11
+ const names = new Set();
12
+ return rules.map((rule) => {
13
+ const fail = (reason) => { throw new DbCompileError('JD0005', reason, path); };
14
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule)) fail('an invariant must be an object');
15
+ for (const key of Object.keys(rule))
16
+ if (!['name', 'on', 'assert', 'enforcement', 'audit'].includes(key)) fail(`unknown invariant member '${key}'`);
17
+ if (typeof rule.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(rule.name) || names.has(rule.name)) fail('invariant names must be distinct identifiers');
18
+ names.add(rule.name);
19
+ if (!['database', 'store'].includes(rule.enforcement)) fail('invariant enforcement must be database or store');
20
+ if (!Array.isArray(rule.on) || !rule.on.length || new Set(rule.on).size !== rule.on.length
21
+ || rule.on.some((op) => !['insert', 'update', 'delete'].includes(op))) fail('invariant on must name distinct insert/update/delete operations');
22
+ if (rule.assert === undefined) fail('invariant assert is required');
23
+ if (rule.audit !== undefined && (rule.enforcement !== 'database' || !rule.audit
24
+ || typeof rule.audit.entity !== 'string' || !rule.audit.values || typeof rule.audit.values !== 'object'
25
+ || Array.isArray(rule.audit.values) || Object.keys(rule.audit).some((k) => !['entity', 'values'].includes(k)))) fail('audit is a database effect with an entity and values');
26
+ return { ...rule, evaluate: compileJsonQuery(rule.assert) };
27
+ });
28
+ }
29
+
30
+ /** @param {string} name @returns {DbRuntimeError} */
31
+ export function invariantFailure(name) {
32
+ const error = new DbRuntimeError('JD2096', `persistence invariant '${name}' rejected the mutation`);
33
+ error.class = 'constraint';
34
+ error.retryable = false;
35
+ return error;
36
+ }
37
+
38
+ /** @param {any[]} rules @param {string} op @param {any} before @param {any} after */
39
+ export function checkInvariants(rules, op, before, after) {
40
+ for (const rule of rules) {
41
+ if (rule.enforcement === 'store' && rule.on.includes(op)
42
+ && rule.evaluate({ old: before ?? null, new: after ?? null, op }) !== true)
43
+ throw invariantFailure(rule.name);
44
+ }
45
+ }
package/src/jobs.js CHANGED
@@ -194,7 +194,7 @@ function isLease(value) {
194
194
  /**
195
195
  * The queue engine over one open connection.
196
196
  * @param {{ connection: any, now?: () => number,
197
- * random?: () => number,
197
+ * random?: () => number, adopt?: boolean,
198
198
  * gate?: (fn: () => any, what?: string, signal?: AbortSignal) => any,
199
199
  * bracket?: (fn: () => any) => any,
200
200
  * defaults?: Partial<typeof JOB_DEFAULTS>,
@@ -276,9 +276,22 @@ export function createJobEngine(options) {
276
276
 
277
277
  // the tables are created here, or refused here: a read-only store
278
278
  // leaked the driver's "attempt to write a readonly database"
279
- const ready = attempt(() => bracket(() => chain(connection.exec(CREATE_JOBS), upgradeColumns)),
279
+ // Adoption accepts the current engine-owned schema without provisioning or
280
+ // upgrading it. The DDL remains the single schema declaration; differently
281
+ // shaped historical queues need an explicit upgrade before adoption.
282
+ const verifyExisting = () => chain(connection.prepare(connection.dialect.introspect.objects()), (statement) => chain(statement.all([]), (objects) => {
283
+ const normalize = (sql) => sql.replace(/'[^']*'|\bIF\s+NOT\s+EXISTS\b|[\s";]/g, (part) => part.startsWith("'") ? part : '');
284
+ for (const sql of CREATE_JOBS.split(';').filter((part) => part.trim())) {
285
+ const name = sql.match(/(?:TABLE|INDEX) IF NOT EXISTS "([^"]+)"/)[1];
286
+ const object = objects.find((value) => value.name === name);
287
+ if (!object?.sql || normalize(object.sql) !== normalize(sql))
288
+ throw new Error(`existing job object ${name} needs an explicit schema migration`);
289
+ }
290
+ }));
291
+ const ready = attempt(() => bracket(() => options.adopt === true
292
+ ? verifyExisting() : chain(connection.exec(CREATE_JOBS), upgradeColumns)),
280
293
  (error) => new DbCompileError('JD0002',
281
- `the job tables could not be created (${error?.message ?? String(error)}) — `
294
+ `the job tables could not be opened (${error?.message ?? String(error)}) — `
282
295
  + 'a read-only store creates nothing; open it read-write once, or without jobs',
283
296
  '/jobs', error));
284
297
 
@@ -436,6 +449,21 @@ export function createJobEngine(options) {
436
449
  { docPath: '/jobs', collection: JOBS_TABLE });
437
450
  };
438
451
 
452
+ /** Verify current execution authority without renewing or writing a checkpoint.
453
+ * Inside tx.jobs this check and mapped record writes share the same lock.
454
+ * @param {any} lease */
455
+ const assertLease = (lease) => {
456
+ const misuse = requireLease(lease, 'assertLease()');
457
+ if (misuse !== null) throw misuse;
458
+ return chain(prepared('assertLease', `SELECT id FROM "${JOBS_TABLE}" WHERE id=? AND ${FENCE}`)
459
+ .get([lease.jobId, lease.token, now()]), (row) => {
460
+ if (row !== undefined) return true;
461
+ return chain(refuseSettlement(lease, 'assertLease()'), (error) => {
462
+ throw error ?? new DbRuntimeError('JD2065', 'the job is already settled', { docPath: '/jobs' });
463
+ });
464
+ });
465
+ };
466
+
439
467
  /** §4: the jittered exponential backoff. */
440
468
  const backoffOf = (attempts, workerDefaults) => {
441
469
  const base = workerDefaults?.backoffBase ?? defaults.backoffBase;
@@ -849,10 +877,11 @@ export function createJobEngine(options) {
849
877
  * @param {{ handlers: Record<string, Function>, concurrency?: number,
850
878
  * pollInterval?: number, leaseMs?: number, owner?: string,
851
879
  * backoffBase?: number, backoffCap?: number, renew?: boolean,
852
- * onOutcome?: (event: any) => void }} workerOptions
880
+ * onOutcome?: (event: any) => void, effectSafety?: (job: any, context: any) => any }} workerOptions
853
881
  */
854
882
  const createWorker = (workerOptions) => {
855
883
  const handlers = workerOptions?.handlers;
884
+ if (workerOptions?.effectSafety !== undefined && typeof workerOptions.effectSafety !== 'function') throw new TypeError('effectSafety must be a function');
856
885
  if (handlers === null || typeof handlers !== 'object'
857
886
  || Object.keys(handlers).length === 0
858
887
  || Object.values(handlers).some((handler) => typeof handler !== 'function')) {
@@ -1143,8 +1172,11 @@ export function createJobEngine(options) {
1143
1172
  try {
1144
1173
  let result;
1145
1174
  try {
1146
- result = await handlers[job.kind](job.payload,
1147
- { job, checkpoints: attempt.checkpoints, signal: attempt.signal });
1175
+ const context = { job, lease: () => attempt.lease, checkpoints: attempt.checkpoints, signal: attempt.signal,
1176
+ pause: () => io(() => cancel(job.id, { lease: attempt.lease }), 'a durable effect pause') };
1177
+ const admitted = workerOptions.effectSafety === undefined || await workerOptions.effectSafety(job, context) === true;
1178
+ if (!admitted) { await context.pause(); recordCancelled(attempt); return; }
1179
+ result = await handlers[job.kind](job.payload, context);
1148
1180
  }
1149
1181
  catch (error) {
1150
1182
  // past cancellation the store is closing: leave the leased
@@ -1330,6 +1362,7 @@ export function createJobEngine(options) {
1330
1362
  counts,
1331
1363
  claim,
1332
1364
  renew,
1365
+ assertLease,
1333
1366
  complete,
1334
1367
  fail,
1335
1368
  checkpointsFor,
@@ -1,5 +1,5 @@
1
1
  //@ts-check
2
- /** Two-level groups use bounded per-parent recomputation over maintained leaves. */
2
+ /** Collection groups use bounded per-group recomputation over maintained leaves. */
3
3
  import { compileJsonQuery } from '@jarenjs/json/query';
4
4
  import { stableStringify } from '@jarenjs/core/object';
5
5
  import { DbRuntimeError } from './errors.js';
@@ -47,6 +47,8 @@ export function nestedGroupStrategy(description, context) {
47
47
  const keyOf = compileJsonQuery([{ $for: { [binding]: '$[*]' },
48
48
  ...(inner.$where === undefined ? {} : { $where: inner.$where }), $return: [key] }]);
49
49
  const evaluate = compileJsonQuery([inner]);
50
+ const aggregate = description.aggregate === undefined ? null
51
+ : compileJsonQuery([{ [description.aggregate]: '$[*]' }]);
50
52
  const docs = new Map();
51
53
  const groups = new Map();
52
54
  const results = new Map();
@@ -55,18 +57,25 @@ export function nestedGroupStrategy(description, context) {
55
57
  let resultCount = 0;
56
58
  const bytesOf = (value) => utf8Length(stableStringify(value));
57
59
  const stats = { refreshedGroups: 0, dependencyReads: 0, reruns: 0 };
58
- const groupOf = (doc) => stableStringify(keyOf([doc], context.externals));
60
+ const groupOf = (doc) => {
61
+ const keys = keyOf([doc], context.externals);
62
+ return keys.length === 0 ? null : stableStringify(keys);
63
+ };
59
64
  const sorted = (keys) => [...keys].sort((a, b) => positions.get(a) - positions.get(b));
60
65
  const check = () => {
61
- const entries = docs.size + resultCount;
66
+ const entries = docs.size + resultCount + (aggregate === null ? 0 : 1);
62
67
  if (entries > context.maxMaintained) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxMaintained');
63
68
  if (maintainedBytes > context.maxBytes) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxBytes');
64
69
  return entries;
65
70
  };
66
71
  const add = (token, doc) => {
67
72
  const group = groupOf(doc);
73
+ if (group === null) return null;
74
+ const bytes = bytesOf(doc);
75
+ if (docs.size + resultCount + 1 > context.maxMaintained || maintainedBytes + bytes > context.maxBytes)
76
+ throw new DbRuntimeError('JD2060', 'group dependencies exceed live state credits');
68
77
  docs.set(token, doc);
69
- maintainedBytes += bytesOf(doc);
78
+ maintainedBytes += bytes;
70
79
  positions.set(token, context.rowPosition(token));
71
80
  if (!groups.has(group)) groups.set(group, new Set());
72
81
  groups.get(group).add(token);
@@ -87,20 +96,25 @@ export function nestedGroupStrategy(description, context) {
87
96
  stats.refreshedGroups++;
88
97
  check();
89
98
  };
90
- const flatten = () => [...groups.keys()].sort((a, b) =>
91
- positions.get(sorted(groups.get(a))[0]) - positions.get(sorted(groups.get(b))[0]))
92
- .flatMap((group) => results.get(group) ?? []);
99
+ const flatten = () => {
100
+ const rows = [...groups.keys()].sort((a, b) =>
101
+ positions.get(sorted(groups.get(a))[0]) - positions.get(sorted(groups.get(b))[0]))
102
+ .flatMap((group) => results.get(group) ?? []);
103
+ return aggregate === null ? rows : aggregate(rows, context.externals);
104
+ };
93
105
  return {
94
106
  close() { docs.clear(); groups.clear(); results.clear(); positions.clear(); },
95
107
  init() {
96
- const source = [{ $subsequence: [{ $for: { it: '$[*]' }, $return: '$it' }, 0, context.maxMaintained + 1] }];
108
+ const carrier = description.carrier ?? { $for: { [binding]: '$[*]' },
109
+ ...(inner.$where === undefined ? {} : { $where: inner.$where }), $return: `$${binding}` };
110
+ const source = [{ $subsequence: [carrier, 0, context.maxMaintained + 1] }];
97
111
  for (const doc of context.execute(source, { externals: context.externals })) add(context.keyOf(doc), doc);
98
112
  check();
99
113
  for (const group of groups.keys()) refresh(group);
100
114
  return flatten();
101
115
  }, entries: check, stats: () => ({ ...stats }),
102
116
  apply(record, previousRows) {
103
- const changes = context.touchedKeys(record, { whole: true, members: new Set() });
117
+ const changes = context.touchedKeys(record, description.deps ?? { whole: true, members: new Set() });
104
118
  if (changes === null) return null;
105
119
  const affected = new Set();
106
120
  for (const token of changes.keys()) {
@@ -110,7 +124,10 @@ export function nestedGroupStrategy(description, context) {
110
124
  docs.delete(token); positions.delete(token); maintainedBytes -= bytesOf(previous);
111
125
  }
112
126
  const doc = context.readRow(token); stats.dependencyReads++;
113
- if (doc !== undefined) affected.add(add(token, doc));
127
+ if (doc !== undefined) {
128
+ const group = add(token, doc);
129
+ if (group !== null) affected.add(group);
130
+ }
114
131
  }
115
132
  check();
116
133
  for (const group of affected) refresh(group);