@cnstra/persist-postgres 0.2.0 → 0.5.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/index.js CHANGED
@@ -23,10 +23,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
25
  CNSPostgresStimulationRepository: () => CNSPostgresStimulationRepository,
26
- DEFAULT_PREFIX: () => DEFAULT_PREFIX,
27
- assertSafePrefix: () => assertSafePrefix,
28
- ensureSchema: () => ensureSchema,
29
- persistPostgresSchemaSql: () => persistPostgresSchemaSql
26
+ CNSPostgresStimulationSchema: () => CNSPostgresStimulationSchema
30
27
  });
31
28
  module.exports = __toCommonJS(index_exports);
32
29
 
@@ -34,48 +31,260 @@ module.exports = __toCommonJS(index_exports);
34
31
  var import_pg = require("pg");
35
32
  var import_persist = require("@cnstra/persist");
36
33
 
37
- // src/migrate.ts
38
- var DEFAULT_PREFIX = "cns_";
39
- function assertSafePrefix(prefix) {
40
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
41
- throw new Error(`[@cnstra/persist-postgres] unsafe table prefix: ${JSON.stringify(prefix)}`);
34
+ // ../postgres-schema/src/CNSPostgresSchemaMigrator.ts
35
+ var _CNSPostgresSchemaMigrator = class _CNSPostgresSchemaMigrator {
36
+ constructor(options) {
37
+ __publicField(this, "options");
38
+ __publicField(this, "steps");
39
+ var _a;
40
+ this.options = options;
41
+ this.steps = [
42
+ ...options.steps
43
+ ].sort((left, right) => left.step - right.step);
44
+ this.steps.forEach((step, index) => {
45
+ if (step.step !== index + 1) throw new Error(`[${options.name}] schema steps must be numbered 1, 2, 3\u2026; found ${step.step} at position ${index + 1}`);
46
+ });
47
+ if (((_a = this.steps[0]) == null ? void 0 : _a.kind) !== "expand") throw new Error(`[${options.name}] schema step 1 must be an expand step`);
42
48
  }
43
- return prefix;
44
- }
45
- __name(assertSafePrefix, "assertSafePrefix");
46
- function persistPostgresSchemaSql(prefix = DEFAULT_PREFIX) {
47
- const p = assertSafePrefix(prefix);
48
- return `
49
- CREATE TABLE IF NOT EXISTS ${p}stimulation (
49
+ migrationTable(tablePrefix) {
50
+ return `${tablePrefix}${this.options.name}_migration`;
51
+ }
52
+ async migrate(database, tablePrefix, options = {}) {
53
+ await database.transaction(async (connection) => {
54
+ await connection.query("SELECT pg_advisory_xact_lock($1, hashtext($2))", [
55
+ _CNSPostgresSchemaMigrator.LOCK_NAMESPACE,
56
+ tablePrefix + this.options.name
57
+ ]);
58
+ await connection.query(`SET LOCAL lock_timeout = '${_CNSPostgresSchemaMigrator.LOCK_TIMEOUT}'`);
59
+ const applied = await this.readAppliedSteps(connection, tablePrefix);
60
+ if (applied === void 0) await this.assertNoUntrackedTables(connection, tablePrefix);
61
+ const pending = this.pendingSteps(applied != null ? applied : /* @__PURE__ */ new Map(), applied === void 0 || options.contract === true);
62
+ if (pending.length > 0) await connection.query(this.sqlFor(tablePrefix, pending, applied === void 0));
63
+ });
64
+ }
65
+ async verify(connection, tablePrefix) {
66
+ const applied = await this.readAppliedSteps(connection, tablePrefix);
67
+ if (applied === void 0) {
68
+ throw new Error(`[${this.options.name}] the schema is not installed under prefix "${tablePrefix}": run migrate()`);
69
+ }
70
+ const missing = this.steps.filter((step) => step.kind === "expand" && !applied.has(step.step)).map((step) => step.step);
71
+ if (missing.length > 0) {
72
+ throw new Error(`[${this.options.name}] schema steps ${missing.join(", ")} are not applied under prefix "${tablePrefix}": run migrate()`);
73
+ }
74
+ const unknown = [
75
+ ...applied
76
+ ].filter(([step, kind]) => kind === "contract" && step > this.steps.length).map(([step]) => step);
77
+ if (unknown.length > 0) {
78
+ throw new Error(`[${this.options.name}] the database has contract steps ${unknown.join(", ")} that this version does not know: upgrade the package`);
79
+ }
80
+ }
81
+ migrationSql(tablePrefix, appliedSteps = [], options = {}) {
82
+ const fresh = appliedSteps.length === 0;
83
+ const applied = new Map(appliedSteps.map((step) => [
84
+ step,
85
+ "expand"
86
+ ]));
87
+ return this.sqlFor(tablePrefix, this.pendingSteps(applied, fresh || options.contract === true), fresh);
88
+ }
89
+ assertTablePrefix(tablePrefix, longestSuffix) {
90
+ const longest = Math.max(longestSuffix.length, `${this.options.name}_migration`.length);
91
+ const limit = _CNSPostgresSchemaMigrator.MAX_IDENTIFIER_LENGTH - longest;
92
+ if (!new RegExp(`^[A-Za-z_][A-Za-z0-9_]{0,${limit - 1}}$`).test(tablePrefix)) {
93
+ throw new Error(`[${this.options.name}] unsafe table prefix ${JSON.stringify(tablePrefix)}: letters, digits and _ only, at most ${limit} characters`);
94
+ }
95
+ return tablePrefix;
96
+ }
97
+ pendingSteps(applied, withContract) {
98
+ return this.steps.filter((step) => !applied.has(step.step) && (step.kind === "expand" || withContract));
99
+ }
100
+ sqlFor(tablePrefix, steps, createMigrationTable) {
101
+ const table = this.migrationTable(tablePrefix);
102
+ const parts = createMigrationTable ? [
103
+ `CREATE TABLE ${table} (step integer PRIMARY KEY, kind text NOT NULL CHECK (kind IN ('expand', 'contract')));`
104
+ ] : [];
105
+ for (const step of steps) {
106
+ parts.push(step.sql(tablePrefix), `INSERT INTO ${table} (step, kind) VALUES (${step.step}, '${step.kind}');`);
107
+ }
108
+ return parts.join("\n");
109
+ }
110
+ async readAppliedSteps(connection, tablePrefix) {
111
+ var _a;
112
+ const table = this.migrationTable(tablePrefix);
113
+ const exists = await connection.query("SELECT to_regclass($1)::text AS found", [
114
+ table
115
+ ]);
116
+ if (((_a = exists.rows[0]) == null ? void 0 : _a.found) == null) return void 0;
117
+ const rows = await connection.query(`SELECT step, kind FROM ${table}`);
118
+ return new Map(rows.rows.map((row) => [
119
+ Number(row.step),
120
+ row.kind
121
+ ]));
122
+ }
123
+ async assertNoUntrackedTables(connection, tablePrefix) {
124
+ const tables = this.options.tables(tablePrefix);
125
+ const found = await connection.query("SELECT name FROM unnest($1::text[]) AS name WHERE to_regclass(name) IS NOT NULL", [
126
+ tables
127
+ ]);
128
+ if (found.rows.length > 0) {
129
+ throw new Error(`[${this.options.name}] tables ${found.rows.map((row) => row.name).join(", ")} exist without ${this.migrationTable(tablePrefix)}: they were created by an earlier unversioned release; drop them or move the data before migrate()`);
130
+ }
131
+ }
132
+ };
133
+ __name(_CNSPostgresSchemaMigrator, "CNSPostgresSchemaMigrator");
134
+ __publicField(_CNSPostgresSchemaMigrator, "LOCK_NAMESPACE", 6);
135
+ __publicField(_CNSPostgresSchemaMigrator, "LOCK_TIMEOUT", "5s");
136
+ __publicField(_CNSPostgresSchemaMigrator, "MAX_IDENTIFIER_LENGTH", 63);
137
+ var CNSPostgresSchemaMigrator = _CNSPostgresSchemaMigrator;
138
+
139
+ // ../postgres-schema/src/CNSPostgresSchemaPgDatabase.ts
140
+ var _CNSPostgresSchemaPgDatabase = class _CNSPostgresSchemaPgDatabase {
141
+ constructor(open) {
142
+ __publicField(this, "open");
143
+ this.open = open;
144
+ }
145
+ static fromPool(pool) {
146
+ return new _CNSPostgresSchemaPgDatabase(async () => {
147
+ const client = await pool.connect();
148
+ return {
149
+ connection: client,
150
+ release: /* @__PURE__ */ __name((error) => client.release(error), "release")
151
+ };
152
+ });
153
+ }
154
+ async transaction(work) {
155
+ const { connection, release } = await this.open();
156
+ try {
157
+ await connection.query("BEGIN");
158
+ const result = await work(connection);
159
+ await connection.query("COMMIT");
160
+ release();
161
+ return result;
162
+ } catch (error) {
163
+ const rollback = await connection.query("ROLLBACK").then(() => void 0, (rollbackError) => rollbackError);
164
+ release(rollback instanceof Error ? rollback : void 0);
165
+ throw error;
166
+ }
167
+ }
168
+ };
169
+ __name(_CNSPostgresSchemaPgDatabase, "CNSPostgresSchemaPgDatabase");
170
+ var CNSPostgresSchemaPgDatabase = _CNSPostgresSchemaPgDatabase;
171
+
172
+ // ../postgres-schema/src/CNSPostgresSchemaPartitionRotation.ts
173
+ var _CNSPostgresSchemaPartitionRotation = class _CNSPostgresSchemaPartitionRotation {
174
+ constructor(options) {
175
+ __publicField(this, "options");
176
+ __publicField(this, "rotatedFor", /* @__PURE__ */ new Map());
177
+ this.options = options;
178
+ if (!(options.premakeDays >= 1) || !Number.isInteger(options.premakeDays)) {
179
+ throw new Error(`[partition rotation] premakeDays must be a positive integer, got ${options.premakeDays}`);
180
+ }
181
+ }
182
+ async rotate(database, table, now, dropBefore, beforeDrop) {
183
+ const today = Math.floor(now / _CNSPostgresSchemaPartitionRotation.DAY_MS);
184
+ const days = `${today}:${Math.floor(dropBefore / _CNSPostgresSchemaPartitionRotation.DAY_MS)}`;
185
+ if (this.rotatedFor.get(table) === days) return [];
186
+ const dropped = await this.underLock(database, table, async (connection) => {
187
+ const expired = (await this.readRangePartitions(connection, table)).filter((partition) => partition.to <= dropBefore);
188
+ if (beforeDrop) for (const partition of expired) await beforeDrop(connection, partition.name);
189
+ for (const partition of expired) await connection.query(`DROP TABLE ${partition.name}`);
190
+ return expired.map((partition) => partition.name);
191
+ });
192
+ await this.underLock(database, table, async (connection) => {
193
+ const partitions = await this.readRangePartitions(connection, table);
194
+ for (let ahead = 1; ahead <= this.options.premakeDays; ahead++) {
195
+ const from = (today + ahead) * _CNSPostgresSchemaPartitionRotation.DAY_MS;
196
+ const to = from + _CNSPostgresSchemaPartitionRotation.DAY_MS;
197
+ if (partitions.some((partition) => partition.from < to && from < partition.to)) continue;
198
+ const name = `${table}_p${new Date(from).toISOString().slice(0, 10).replace(/-/g, "")}`;
199
+ await connection.query(`CREATE TABLE ${name} (LIKE ${table} INCLUDING DEFAULTS INCLUDING CONSTRAINTS)`);
200
+ await connection.query(`ALTER TABLE ${table} ATTACH PARTITION ${name} FOR VALUES FROM (${from}) TO (${to})`);
201
+ }
202
+ });
203
+ this.rotatedFor.set(table, days);
204
+ return dropped;
205
+ }
206
+ underLock(database, table, work) {
207
+ return database.transaction(async (connection) => {
208
+ await connection.query("SELECT pg_advisory_xact_lock($1, hashtext($2))", [
209
+ _CNSPostgresSchemaPartitionRotation.LOCK_NAMESPACE,
210
+ table
211
+ ]);
212
+ await connection.query(`SET LOCAL lock_timeout = '${_CNSPostgresSchemaPartitionRotation.LOCK_TIMEOUT}'`);
213
+ return work(connection);
214
+ });
215
+ }
216
+ async readRangePartitions(connection, table) {
217
+ const rows = await connection.query(`SELECT child.oid::regclass::text AS name, pg_get_expr(child.relpartbound, child.oid) AS bound
218
+ FROM pg_inherits
219
+ JOIN pg_class child ON child.oid = pg_inherits.inhrelid
220
+ WHERE pg_inherits.inhparent = $1::regclass`, [
221
+ table
222
+ ]);
223
+ const partitions = [];
224
+ for (const row of rows.rows) {
225
+ const range = /^FOR VALUES FROM \('?(-?[\d.e+]+)'?\) TO \('?(-?[\d.e+]+)'?\)$/.exec(row.bound);
226
+ if (range) partitions.push({
227
+ name: row.name,
228
+ from: Number(range[1]),
229
+ to: Number(range[2])
230
+ });
231
+ }
232
+ return partitions;
233
+ }
234
+ };
235
+ __name(_CNSPostgresSchemaPartitionRotation, "CNSPostgresSchemaPartitionRotation");
236
+ __publicField(_CNSPostgresSchemaPartitionRotation, "LOCK_NAMESPACE", 8);
237
+ __publicField(_CNSPostgresSchemaPartitionRotation, "LOCK_TIMEOUT", "5s");
238
+ __publicField(_CNSPostgresSchemaPartitionRotation, "DAY_MS", 24 * 60 * 60 * 1e3);
239
+ var CNSPostgresSchemaPartitionRotation = _CNSPostgresSchemaPartitionRotation;
240
+
241
+ // src/schema/CNSPostgresStimulationSchema.ts
242
+ var _CNSPostgresStimulationSchema = class _CNSPostgresStimulationSchema {
243
+ static assertTablePrefix(tablePrefix) {
244
+ return _CNSPostgresStimulationSchema.migrator.assertTablePrefix(tablePrefix, _CNSPostgresStimulationSchema.LONGEST_SUFFIX);
245
+ }
246
+ static tables(tablePrefix = _CNSPostgresStimulationSchema.DEFAULT_TABLE_PREFIX) {
247
+ const p = _CNSPostgresStimulationSchema.assertTablePrefix(tablePrefix);
248
+ return [
249
+ `${p}stimulation`,
250
+ `${p}stimulation_attempt`,
251
+ `${p}stimulation_task`
252
+ ];
253
+ }
254
+ static migrationTable(tablePrefix = _CNSPostgresStimulationSchema.DEFAULT_TABLE_PREFIX) {
255
+ return _CNSPostgresStimulationSchema.migrator.migrationTable(_CNSPostgresStimulationSchema.assertTablePrefix(tablePrefix));
256
+ }
257
+ static migrationSql(tablePrefix = _CNSPostgresStimulationSchema.DEFAULT_TABLE_PREFIX, appliedSteps = [], options) {
258
+ return _CNSPostgresStimulationSchema.migrator.migrationSql(_CNSPostgresStimulationSchema.assertTablePrefix(tablePrefix), appliedSteps, options);
259
+ }
260
+ static async migrate(pool, tablePrefix = _CNSPostgresStimulationSchema.DEFAULT_TABLE_PREFIX, options) {
261
+ await _CNSPostgresStimulationSchema.migrator.migrate(CNSPostgresSchemaPgDatabase.fromPool(pool), _CNSPostgresStimulationSchema.assertTablePrefix(tablePrefix), options);
262
+ }
263
+ static async verify(database, tablePrefix = _CNSPostgresStimulationSchema.DEFAULT_TABLE_PREFIX) {
264
+ await _CNSPostgresStimulationSchema.migrator.verify(database, _CNSPostgresStimulationSchema.assertTablePrefix(tablePrefix));
265
+ }
266
+ static initialSql(p) {
267
+ return `
268
+ CREATE TABLE ${p}stimulation (
50
269
  stimulation_id text PRIMARY KEY,
51
270
  entry jsonb NOT NULL,
52
- status text NOT NULL,
271
+ status text NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'aborted')),
53
272
  scope_name text,
54
- created_at timestamptz NOT NULL DEFAULT now(),
55
- updated_at timestamptz NOT NULL DEFAULT now()
56
- );
57
- CREATE INDEX IF NOT EXISTS ${p}stimulation_updated_idx ON ${p}stimulation (updated_at DESC);
58
- CREATE INDEX IF NOT EXISTS ${p}stimulation_scope_idx ON ${p}stimulation (scope_name);
273
+ progress jsonb NOT NULL,
274
+ updated_at bigint NOT NULL,
275
+ started_at bigint NOT NULL DEFAULT 0,
276
+ hop_count int NOT NULL DEFAULT 0,
277
+ last_completed_at bigint
278
+ ) WITH (fillfactor = 70);
279
+ CREATE INDEX ${p}stimulation_started_idx ON ${p}stimulation (started_at DESC, stimulation_id COLLATE "C" DESC);
280
+ CREATE INDEX ${p}stimulation_scope_started_idx ON ${p}stimulation (scope_name, started_at DESC, stimulation_id COLLATE "C" DESC);
59
281
 
60
- CREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (
61
- stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,
62
- ord int NOT NULL,
63
- neuron_name text NOT NULL,
64
- dendrite_collateral_name text NOT NULL,
65
- input jsonb,
66
- PRIMARY KEY (stimulation_id, ord)
67
- );
68
-
69
- -- NOTE: no FK on stimulation_id \u2014 over the devtools wire the persistor's
70
- -- saveStimulation and saveAttempt ride SEPARATE async batches processed
71
- -- concurrently by the server, so an attempt insert can reach Postgres before its
72
- -- stimulation row's transaction commits; a hard FK would reject it (23503). Same
73
- -- exposure as ${p}task; delete() removes attempts explicitly instead of cascading.
74
- CREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (
282
+ -- No foreign keys below: attempts and tasks arrive over separate writes, often before their parent row.
283
+ CREATE TABLE ${p}stimulation_attempt (
75
284
  stimulation_attempt_id text PRIMARY KEY,
76
285
  stimulation_id text NOT NULL,
77
286
  attempt_number int NOT NULL,
78
- status text NOT NULL,
287
+ status text NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'aborted')),
79
288
  started_at bigint NOT NULL,
80
289
  completed_at bigint,
81
290
  hop_count int NOT NULL,
@@ -84,35 +293,41 @@ CREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (
84
293
  entry jsonb NOT NULL,
85
294
  UNIQUE (stimulation_id, attempt_number)
86
295
  );
87
- CREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);
88
- -- roster query: MIN(started_at)/keyset ordering per run.
89
- CREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_started_idx ON ${p}stimulation_attempt (stimulation_id, started_at);
296
+ CREATE INDEX ${p}stimulation_attempt_started_idx ON ${p}stimulation_attempt (started_at);
90
297
 
91
- -- NOTE: no FK on stimulation_attempt_id \u2014 the persistor streams settled tasks
92
- -- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),
93
- -- so a hard FK would reject the insert. delete() removes tasks explicitly instead.
94
- CREATE TABLE IF NOT EXISTS ${p}task (
298
+ CREATE TABLE ${p}stimulation_task (
95
299
  stimulation_attempt_id text NOT NULL,
96
- "index" int NOT NULL,
300
+ task_index int NOT NULL,
97
301
  neuron_name text NOT NULL,
98
302
  dendrite_collateral_name text NOT NULL,
99
303
  input_index int NOT NULL,
100
304
  output jsonb,
101
- status text NOT NULL,
305
+ status text NOT NULL CHECK (status IN ('done', 'failed')),
102
306
  error text,
103
307
  started_at bigint NOT NULL,
104
- duration int,
105
- PRIMARY KEY (stimulation_attempt_id, "index")
106
- );
107
- -- roster neurons lens: DISTINCT neuron_name per attempt / neuronName filter.
108
- CREATE INDEX IF NOT EXISTS ${p}task_neuron_idx ON ${p}task (neuron_name);
308
+ duration_ms bigint,
309
+ activation_context jsonb,
310
+ PRIMARY KEY (stimulation_attempt_id, task_index, started_at)
311
+ ) PARTITION BY RANGE (started_at);
312
+ CREATE TABLE ${p}stimulation_task_default PARTITION OF ${p}stimulation_task DEFAULT;
109
313
  `;
110
- }
111
- __name(persistPostgresSchemaSql, "persistPostgresSchemaSql");
112
- async function ensureSchema(db, prefix = DEFAULT_PREFIX) {
113
- await db.query(persistPostgresSchemaSql(prefix));
114
- }
115
- __name(ensureSchema, "ensureSchema");
314
+ }
315
+ };
316
+ __name(_CNSPostgresStimulationSchema, "CNSPostgresStimulationSchema");
317
+ __publicField(_CNSPostgresStimulationSchema, "DEFAULT_TABLE_PREFIX", "cns_");
318
+ __publicField(_CNSPostgresStimulationSchema, "LONGEST_SUFFIX", "stimulation_attempt_started_idx");
319
+ __publicField(_CNSPostgresStimulationSchema, "migrator", new CNSPostgresSchemaMigrator({
320
+ name: "stimulation",
321
+ tables: /* @__PURE__ */ __name((tablePrefix) => _CNSPostgresStimulationSchema.tables(tablePrefix), "tables"),
322
+ steps: [
323
+ {
324
+ step: 1,
325
+ kind: "expand",
326
+ sql: /* @__PURE__ */ __name((tablePrefix) => _CNSPostgresStimulationSchema.initialSql(tablePrefix), "sql")
327
+ }
328
+ ]
329
+ }));
330
+ var CNSPostgresStimulationSchema = _CNSPostgresStimulationSchema;
116
331
 
117
332
  // src/CNSPostgresStimulationRepository.ts
118
333
  var j = /* @__PURE__ */ __name((v) => JSON.stringify(v != null ? v : null), "j");
@@ -125,8 +340,12 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
125
340
  __publicField(this, "ownsPool");
126
341
  __publicField(this, "p");
127
342
  __publicField(this, "autoMigrate");
343
+ __publicField(this, "now");
344
+ __publicField(this, "taskPartitions", new CNSPostgresSchemaPartitionRotation({
345
+ premakeDays: 7
346
+ }));
128
347
  __publicField(this, "ready");
129
- var _a, _b;
348
+ var _a, _b, _c;
130
349
  if (opts.pool) {
131
350
  this.pool = opts.pool;
132
351
  this.db = opts.pool;
@@ -143,13 +362,21 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
143
362
  } else {
144
363
  throw new Error("[@cnstra/persist-postgres] needs `connectionString`, `pool`, or `queryable`");
145
364
  }
146
- this.p = assertSafePrefix((_a = opts.tablePrefix) != null ? _a : DEFAULT_PREFIX);
365
+ this.p = CNSPostgresStimulationSchema.assertTablePrefix((_a = opts.tablePrefix) != null ? _a : CNSPostgresStimulationSchema.DEFAULT_TABLE_PREFIX);
147
366
  this.autoMigrate = (_b = opts.autoMigrate) != null ? _b : true;
367
+ if (this.autoMigrate && !this.pool) {
368
+ throw new Error("[@cnstra/persist-postgres] a `queryable` cannot hold the migration transaction: pass `pool` or `connectionString`, or set `autoMigrate: false` and run CNSPostgresStimulationSchema.migrate(pool) yourself");
369
+ }
370
+ this.now = (_c = opts.now) != null ? _c : Date.now;
148
371
  }
149
- ensure() {
372
+ schemaReady() {
150
373
  var _a;
151
374
  if (!this.autoMigrate) return Promise.resolve();
152
- return (_a = this.ready) != null ? _a : this.ready = ensureSchema(this.db, this.p);
375
+ (_a = this.ready) != null ? _a : this.ready = CNSPostgresStimulationSchema.migrate(this.pool, this.p).catch((error) => {
376
+ this.ready = void 0;
377
+ throw error;
378
+ });
379
+ return this.ready;
153
380
  }
154
381
  /** Run `fn` in a transaction when a Pool is available; else best-effort inline. */
155
382
  async tx(fn) {
@@ -169,68 +396,87 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
169
396
  }
170
397
  // ── write ──
171
398
  async saveStimulation(s) {
172
- await this.ensure();
399
+ await this.schemaReady();
173
400
  const { p } = this;
401
+ const now = this.now();
174
402
  await this.tx(async (db) => {
175
- var _a;
176
- await db.query(`INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, updated_at)
177
- VALUES ($1, $2::jsonb, $3, $4, now())
403
+ var _a, _b;
404
+ await this.lockStimulation(db, s.stimulationId);
405
+ const upserted = await db.query(`INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, progress, updated_at)
406
+ VALUES ($1, $2::jsonb, $3, $4, $5::jsonb, $6)
178
407
  ON CONFLICT (stimulation_id)
179
408
  DO UPDATE SET entry = EXCLUDED.entry, status = EXCLUDED.status,
180
- scope_name = EXCLUDED.scope_name, updated_at = now()`, [
409
+ scope_name = EXCLUDED.scope_name, progress = EXCLUDED.progress, updated_at = EXCLUDED.updated_at
410
+ RETURNING (xmax = 0) AS inserted`, [
181
411
  s.stimulationId,
182
412
  j(s.entry),
183
413
  s.status,
184
- (_a = s.scopeName) != null ? _a : null
414
+ (_a = s.scopeName) != null ? _a : null,
415
+ j(s.progress),
416
+ now
185
417
  ]);
186
- await db.query(`DELETE FROM ${p}stimulation_frontier WHERE stimulation_id = $1`, [
187
- s.stimulationId
188
- ]);
189
- for (let i = 0; i < s.progress.tasks.length; i++) {
190
- const t = s.progress.tasks[i];
191
- await db.query(`INSERT INTO ${p}stimulation_frontier
192
- (stimulation_id, ord, neuron_name, dendrite_collateral_name, input)
193
- VALUES ($1, $2, $3, $4, $5::jsonb)`, [
194
- s.stimulationId,
195
- i,
196
- t.neuronName,
197
- t.dendriteCollateralName,
198
- t.input ? j(t.input) : null
199
- ]);
200
- }
418
+ if (((_b = upserted.rows[0]) == null ? void 0 : _b.inserted) !== false) await this.rollUpAttempts(db, s.stimulationId, now);
201
419
  });
202
420
  }
203
421
  async saveAttempt(a) {
204
- await this.ensure();
422
+ await this.schemaReady();
423
+ const { p } = this;
424
+ await this.tx(async (db) => {
425
+ await this.lockStimulation(db, a.stimulationId);
426
+ await db.query(`INSERT INTO ${p}stimulation_attempt
427
+ (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,
428
+ completed_at, hop_count, has_error, replay_of, entry)
429
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
430
+ ON CONFLICT (stimulation_attempt_id) DO UPDATE SET
431
+ status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,
432
+ hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,
433
+ replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`, [
434
+ a.stimulationAttemptId,
435
+ a.stimulationId,
436
+ a.attemptNumber,
437
+ a.status,
438
+ a.startedAt,
439
+ a.completedAt,
440
+ a.hopCount,
441
+ a.hasError,
442
+ a.replayOf,
443
+ j(a.entry)
444
+ ]);
445
+ await this.rollUpAttempts(db, a.stimulationId, this.now());
446
+ });
447
+ }
448
+ async lockStimulation(db, stimulationId) {
449
+ await db.query("SELECT pg_advisory_xact_lock($1, hashtext($2))", [
450
+ _CNSPostgresStimulationRepository.LOCK_STIMULATION,
451
+ this.p + stimulationId
452
+ ]);
453
+ }
454
+ async rollUpAttempts(db, stimulationId, now) {
205
455
  const { p } = this;
206
- await this.db.query(`INSERT INTO ${p}stimulation_attempt
207
- (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,
208
- completed_at, hop_count, has_error, replay_of, entry)
209
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
210
- ON CONFLICT (stimulation_attempt_id) DO UPDATE SET
211
- status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,
212
- hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,
213
- replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`, [
214
- a.stimulationAttemptId,
215
- a.stimulationId,
216
- a.attemptNumber,
217
- a.status,
218
- a.startedAt,
219
- a.completedAt,
220
- a.hopCount,
221
- a.hasError,
222
- a.replayOf,
223
- j(a.entry)
456
+ await db.query(`UPDATE ${p}stimulation s
457
+ SET started_at = COALESCE(a.started_at, 0),
458
+ hop_count = COALESCE(a.hop_count, 0),
459
+ last_completed_at = a.last_completed_at,
460
+ updated_at = GREATEST(s.updated_at, $2)
461
+ FROM (
462
+ SELECT MIN(started_at) AS started_at,
463
+ SUM(hop_count) AS hop_count,
464
+ (ARRAY_AGG(completed_at ORDER BY attempt_number DESC))[1] AS last_completed_at
465
+ FROM ${p}stimulation_attempt WHERE stimulation_id = $1
466
+ ) a
467
+ WHERE s.stimulation_id = $1`, [
468
+ stimulationId,
469
+ now
224
470
  ]);
225
471
  }
226
472
  async appendTask(t) {
227
- await this.ensure();
473
+ await this.schemaReady();
228
474
  const { p } = this;
229
- await this.db.query(`INSERT INTO ${p}task
230
- (stimulation_attempt_id, "index", neuron_name, dendrite_collateral_name,
231
- input_index, output, status, error, started_at, duration)
232
- VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)
233
- ON CONFLICT (stimulation_attempt_id, "index") DO NOTHING`, [
475
+ await this.db.query(`INSERT INTO ${p}stimulation_task
476
+ (stimulation_attempt_id, task_index, neuron_name, dendrite_collateral_name,
477
+ input_index, output, status, error, started_at, duration_ms, activation_context)
478
+ VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11::jsonb)
479
+ ON CONFLICT (stimulation_attempt_id, task_index, started_at) DO NOTHING`, [
234
480
  t.stimulationAttemptId,
235
481
  t.index,
236
482
  t.neuronName,
@@ -240,65 +486,37 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
240
486
  t.status,
241
487
  t.error,
242
488
  t.startedAt,
243
- t.duration
489
+ t.duration,
490
+ t.activationContext ? j(t.activationContext) : null
244
491
  ]);
245
492
  }
246
493
  // ── read ──
247
494
  async getStimulation(stimulationId) {
248
- await this.ensure();
495
+ await this.schemaReady();
249
496
  const { p } = this;
250
- const stimRes = await this.db.query(`SELECT entry, status, scope_name FROM ${p}stimulation WHERE stimulation_id = $1`, [
497
+ const res = await this.db.query(`SELECT stimulation_id, entry, status, scope_name, progress FROM ${p}stimulation WHERE stimulation_id = $1`, [
251
498
  stimulationId
252
499
  ]);
253
- const row = stimRes.rows[0];
254
- if (!row) return void 0;
255
- const frontier = await this.db.query(`SELECT ord, neuron_name, dendrite_collateral_name, input
256
- FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`, [
257
- stimulationId
258
- ]);
259
- return {
260
- stimulationId,
261
- entry: row.entry,
262
- status: row.status,
263
- progress: this.buildProgress(frontier.rows),
264
- ...row.scope_name != null ? {
265
- scopeName: row.scope_name
266
- } : {}
267
- };
500
+ const row = res.rows[0];
501
+ return row ? rowToStimulation(row) : void 0;
268
502
  }
269
503
  async listStimulations(filter) {
270
- var _a;
271
- await this.ensure();
504
+ var _a, _b;
505
+ await this.schemaReady();
272
506
  const { p } = this;
273
507
  const scope = (_a = filter == null ? void 0 : filter.scopeName) != null ? _a : null;
274
- const stimsRes = await this.db.query(`SELECT stimulation_id, entry, status, scope_name FROM ${p}stimulation
508
+ const since = (_b = filter == null ? void 0 : filter.since) != null ? _b : null;
509
+ const res = await this.db.query(`SELECT stimulation_id, entry, status, scope_name, progress FROM ${p}stimulation
275
510
  WHERE ($1::text IS NULL OR scope_name = $1)
511
+ AND ($2::bigint IS NULL OR updated_at >= $2)
276
512
  ORDER BY updated_at DESC`, [
277
- scope
278
- ]);
279
- const stims = stimsRes.rows;
280
- if (stims.length === 0) return [];
281
- const ids = stims.map((r) => r.stimulation_id);
282
- const frontierRes = await this.db.query(`SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input
283
- FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`, [
284
- ids
513
+ scope,
514
+ since
285
515
  ]);
286
- const frontierBy = groupBy(frontierRes.rows, (r) => r.stimulation_id);
287
- return stims.map((r) => {
288
- var _a2;
289
- return {
290
- stimulationId: r.stimulation_id,
291
- entry: r.entry,
292
- status: r.status,
293
- progress: this.buildProgress((_a2 = frontierBy.get(r.stimulation_id)) != null ? _a2 : []),
294
- ...r.scope_name != null ? {
295
- scopeName: r.scope_name
296
- } : {}
297
- };
298
- });
516
+ return res.rows.map(rowToStimulation);
299
517
  }
300
518
  async getAttempts(stimulationId) {
301
- await this.ensure();
519
+ await this.schemaReady();
302
520
  const { p } = this;
303
521
  const res = await this.db.query(`SELECT * FROM ${p}stimulation_attempt WHERE stimulation_id = $1 ORDER BY attempt_number`, [
304
522
  stimulationId
@@ -306,32 +524,92 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
306
524
  return res.rows.map(rowToAttempt);
307
525
  }
308
526
  async getTasks(stimulationAttemptId) {
309
- await this.ensure();
527
+ await this.schemaReady();
310
528
  const { p } = this;
311
- const res = await this.db.query(`SELECT * FROM ${p}task WHERE stimulation_attempt_id = $1 ORDER BY "index"`, [
529
+ const res = await this.db.query(`SELECT * FROM ${p}stimulation_task WHERE stimulation_attempt_id = $1 ORDER BY task_index`, [
312
530
  stimulationAttemptId
313
531
  ]);
314
532
  return res.rows.map(rowToTask);
315
533
  }
534
+ /**
535
+ * Attempts of many stimulations in ONE query, so a page of runs costs one
536
+ * round trip rather than one per run.
537
+ */
538
+ async getAttemptsFor(stimulationIds) {
539
+ if (stimulationIds.length === 0) return [];
540
+ await this.schemaReady();
541
+ const { p } = this;
542
+ const res = await this.db.query(`SELECT * FROM ${p}stimulation_attempt
543
+ WHERE stimulation_id = ANY($1)
544
+ ORDER BY stimulation_id, attempt_number`, [
545
+ stimulationIds
546
+ ]);
547
+ return res.rows.map(rowToAttempt);
548
+ }
549
+ /** Tasks of many attempts in ONE query — see {@link getAttemptsFor}. */
550
+ async getTasksFor(stimulationAttemptIds) {
551
+ if (stimulationAttemptIds.length === 0) return [];
552
+ await this.schemaReady();
553
+ const { p } = this;
554
+ const res = await this.db.query(`SELECT * FROM ${p}stimulation_task
555
+ WHERE stimulation_attempt_id = ANY($1)
556
+ ORDER BY stimulation_attempt_id, task_index`, [
557
+ stimulationAttemptIds
558
+ ]);
559
+ return res.rows.map(rowToTask);
560
+ }
316
561
  // ── roster query capability (filter + keyset + facets pushed into SQL) ──
317
562
  /**
318
- * Derive every filtered roster row in one CTE: `startedAt` = MIN(attempt),
319
- * `completedAt` = the latest attempt's, `hopCount` = SUM, `hasError` from
320
- * status, and the DISTINCT activated neuron names (for the neurons lens +
321
- * filter). Newest-first (`startedAt DESC, stimulationId DESC`) so the keyset
322
- * cursor is stable. `limit` is applied only when paginating (roster); facets
323
- * pull the whole filtered set.
563
+ * The CTE every roster read stands on. The roster fields are kept on the
564
+ * stimulation row by `saveAttempt` / `saveStimulation`, so the window and the
565
+ * order come from `stimulation(started_at DESC, stimulation_id)`; the total
566
+ * still counts the whole filtered set. Shared so the facet tallies are counted
567
+ * over exactly the set the roster pages through.
568
+ * The task table is read only when `withNeurons` asks for the neuron names.
569
+ * It binds `$1..$7` (the filter); the keyset cursor belongs to the roster's tail.
324
570
  */
571
+ rosterCte(withNeurons) {
572
+ const { p } = this;
573
+ const neurons = withNeurons ? `COALESCE((
574
+ SELECT ARRAY_AGG(DISTINCT t.neuron_name)
575
+ FROM ${p}stimulation_task t
576
+ JOIN ${p}stimulation_attempt a ON a.stimulation_attempt_id = t.stimulation_attempt_id
577
+ WHERE a.stimulation_id = s.stimulation_id
578
+ ), ARRAY[]::text[])` : "ARRAY[]::text[]";
579
+ return `
580
+ WITH roster AS (
581
+ SELECT s.stimulation_id, s.entry, s.status, s.scope_name, s.started_at,
582
+ CASE WHEN s.status = 'running' THEN NULL ELSE s.last_completed_at END AS completed_at,
583
+ s.hop_count,
584
+ (s.status = 'failed') AS has_error,
585
+ ${neurons} AS neurons
586
+ FROM ${p}stimulation s
587
+ WHERE ($6::bigint IS NULL OR s.started_at >= $6)
588
+ AND ($7::bigint IS NULL OR s.started_at <= $7)
589
+ AND ($1::text IS NULL OR s.scope_name = $1)
590
+ AND ($2::text IS NULL OR s.status = $2)
591
+ AND ($3::text IS NULL OR s.entry->>'collateralName' = $3)
592
+ AND ($5::text IS NULL OR strpos(lower(s.stimulation_id), $5) > 0)
593
+ ),
594
+ filtered AS (
595
+ SELECT *, COUNT(*) OVER() AS total_count FROM roster
596
+ WHERE ($4::text IS NULL OR $4 = ANY(neurons))
597
+ )`;
598
+ }
325
599
  async selectRoster(filter, opts) {
326
600
  var _a, _b, _c, _d, _e, _f;
327
- await this.ensure();
601
+ await this.schemaReady();
328
602
  const { p } = this;
329
603
  let curStarted = null;
330
604
  let curId = null;
331
605
  if (opts == null ? void 0 : opts.cursor) {
332
- const [ts, id] = Buffer.from(opts.cursor, "base64").toString("utf8").split("|");
333
- curStarted = Number(ts);
334
- curId = id != null ? id : "";
606
+ const decoded = Buffer.from(opts.cursor, "base64").toString("utf8");
607
+ const at = decoded.indexOf("|");
608
+ const started = Number(decoded.slice(0, at));
609
+ if (at >= 0 && Number.isFinite(started)) {
610
+ curStarted = started;
611
+ curId = decoded.slice(at + 1);
612
+ }
335
613
  }
336
614
  const params = [
337
615
  (_a = filter.scopeName) != null ? _a : null,
@@ -344,49 +622,27 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
344
622
  curStarted,
345
623
  curId
346
624
  ];
347
- const limitClause = (opts == null ? void 0 : opts.limit) != null ? ` LIMIT ${Math.max(0, opts.limit | 0)}` : "";
348
- const sql = `
349
- WITH agg AS (
350
- SELECT a.stimulation_id,
351
- MIN(a.started_at) AS started_at,
352
- SUM(a.hop_count) AS hop_count,
353
- (ARRAY_AGG(a.completed_at ORDER BY a.attempt_number DESC))[1] AS last_completed_at
354
- FROM ${p}stimulation_attempt a
355
- GROUP BY a.stimulation_id
356
- ),
357
- neu AS (
358
- SELECT ta.stimulation_id,
359
- ARRAY_AGG(DISTINCT t.neuron_name) AS neurons
360
- FROM ${p}task t
361
- JOIN ${p}stimulation_attempt ta
362
- ON ta.stimulation_attempt_id = t.stimulation_attempt_id
363
- GROUP BY ta.stimulation_id
364
- ),
365
- roster AS (
366
- SELECT s.stimulation_id, s.entry, s.status, s.scope_name,
367
- COALESCE(agg.started_at, 0) AS started_at,
368
- CASE WHEN s.status = 'running' THEN NULL
369
- ELSE agg.last_completed_at END AS completed_at,
370
- COALESCE(agg.hop_count, 0) AS hop_count,
371
- (s.status = 'failed') AS has_error,
372
- COALESCE(neu.neurons, ARRAY[]::text[]) AS neurons
373
- FROM ${p}stimulation s
374
- LEFT JOIN agg ON agg.stimulation_id = s.stimulation_id
375
- LEFT JOIN neu ON neu.stimulation_id = s.stimulation_id
376
- )
377
- SELECT *, COUNT(*) OVER() AS total_count FROM roster
378
- WHERE ($1::text IS NULL OR scope_name = $1)
379
- AND ($2::text IS NULL OR status = $2)
380
- AND ($3::text IS NULL OR entry->>'collateralName' = $3)
381
- AND ($4::text IS NULL OR $4 = ANY(neurons))
382
- AND ($5::text IS NULL OR lower(stimulation_id) LIKE '%'||$5||'%')
383
- AND ($6::bigint IS NULL OR started_at >= $6)
384
- AND ($7::bigint IS NULL OR started_at <= $7)
385
- AND ($8::bigint IS NULL OR (started_at, stimulation_id) < ($8, $9))
386
- ORDER BY started_at DESC, stimulation_id DESC${limitClause}`;
625
+ const limitClause = (opts == null ? void 0 : opts.limit) != null && Number.isFinite(opts.limit) ? ` LIMIT ${Math.max(0, Math.floor(opts.limit))}` : "";
626
+ const sql = `${this.rosterCte(filter.neuronName != null)}
627
+ -- The count is taken INSIDE \`filtered\`, before the keyset predicate
628
+ -- narrows the set. Counting after it made \`total\` shrink with every
629
+ -- page, while the in-memory reference reports the whole filtered set \u2014
630
+ -- and the two are contractually identical.
631
+ SELECT * FROM filtered
632
+ WHERE ($8::bigint IS NULL OR (started_at, stimulation_id COLLATE "C") < ($8, $9::text COLLATE "C"))
633
+ ORDER BY started_at DESC, stimulation_id COLLATE "C" DESC${limitClause}`;
387
634
  const res = await this.db.query(sql, params);
388
635
  const rows = res.rows;
389
- const total = rows.length ? Number(rows[0].total_count) : 0;
636
+ let total = rows.length ? Number(rows[0].total_count) : 0;
637
+ if (!rows.length && curStarted !== null) {
638
+ const countRes = await this.db.query(sql, [
639
+ ...params.slice(0, 7),
640
+ null,
641
+ null
642
+ ]);
643
+ const countRows = countRes.rows;
644
+ total = countRows.length ? Number(countRows[0].total_count) : 0;
645
+ }
390
646
  const entries = rows.map((r) => {
391
647
  var _a2, _b2;
392
648
  return {
@@ -409,26 +665,119 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
409
665
  };
410
666
  }
411
667
  async queryRoster(filter, opts) {
412
- const { entries, total } = await this.selectRoster(filter, opts);
413
- const rows = entries.map((e) => e.row);
668
+ const { entries, total } = await this.selectRoster(filter, {
669
+ ...opts,
670
+ limit: opts.limit + 1
671
+ });
672
+ const hasMore = entries.length > opts.limit;
673
+ const rows = entries.slice(0, opts.limit).map((e) => e.row);
414
674
  const lastRow = rows[rows.length - 1];
415
- const hasMore = rows.length >= opts.limit && rows.length < total;
416
675
  return {
417
676
  rows,
418
- nextCursor: hasMore && lastRow ? (0, import_persist.encodeRosterCursor)(lastRow) : null,
677
+ nextCursor: hasMore && lastRow ? (0, import_persist.encodeCNSRosterCursor)(lastRow) : null,
419
678
  total
420
679
  };
421
680
  }
422
681
  async queryFacets(filter, opts) {
423
- const { entries } = await this.selectRoster(filter);
424
- return (0, import_persist.computeRosterFacets)(entries, opts);
682
+ var _a, _b, _c, _d, _e, _f;
683
+ await this.schemaReady();
684
+ const params = [
685
+ (_a = filter.scopeName) != null ? _a : null,
686
+ (_b = filter.status) != null ? _b : null,
687
+ (_c = filter.entryCollateralName) != null ? _c : null,
688
+ (_d = filter.neuronName) != null ? _d : null,
689
+ filter.text ? filter.text.toLowerCase() : null,
690
+ (_e = filter.since) != null ? _e : null,
691
+ (_f = filter.until) != null ? _f : null,
692
+ // The metric path as a jsonb path array (`a.b` → {a,b}); null when the
693
+ // chart is not summing anything.
694
+ (opts == null ? void 0 : opts.metricPath) ? opts.metricPath.split(".") : null
695
+ ];
696
+ const sql = `${this.rosterCte(true)},
697
+ -- The histogram's bucket width is derived from the set's own span, the
698
+ -- same ${import_persist.CNS_HISTOGRAM_BUCKETS}-bucket rule the reference implementation
699
+ -- uses. Computed here so the whole thing stays one round trip.
700
+ bounds AS (
701
+ SELECT MIN(started_at) AS mn, MAX(started_at) AS mx FROM filtered WHERE started_at > 0
702
+ ),
703
+ step AS (
704
+ SELECT mn,
705
+ GREATEST(1, CEIL((mx - mn)::numeric / ${import_persist.CNS_HISTOGRAM_BUCKETS})::bigint) AS size
706
+ FROM bounds
707
+ )
708
+ SELECT 'entry' AS kind, entry->>'collateralName' AS key,
709
+ COUNT(*)::bigint AS cnt,
710
+ NULL::bigint AS ts_bucket, NULL::bigint AS completed,
711
+ NULL::bigint AS failed, NULL::bigint AS running,
712
+ NULL::numeric AS metric
713
+ FROM filtered GROUP BY 2
714
+ UNION ALL
715
+ SELECT 'status', status, COUNT(*)::bigint,
716
+ NULL, NULL, NULL, NULL, NULL
717
+ FROM filtered GROUP BY 2
718
+ UNION ALL
719
+ -- One row per (run, neuron): the neuron rail counts runs that touched
720
+ -- a neuron, which is what UNNEST over the per-run DISTINCT array gives.
721
+ SELECT 'neuron', n, COUNT(*)::bigint,
722
+ NULL, NULL, NULL, NULL, NULL
723
+ FROM filtered, UNNEST(neurons) AS n GROUP BY 2
724
+ UNION ALL
725
+ SELECT 'time', NULL, NULL,
726
+ (step.mn + ((f.started_at - step.mn) / step.size) * step.size)::bigint,
727
+ COUNT(*) FILTER (WHERE f.status = 'completed')::bigint,
728
+ COUNT(*) FILTER (WHERE f.status = 'failed')::bigint,
729
+ COUNT(*) FILTER (WHERE f.status NOT IN ('completed', 'failed'))::bigint,
730
+ -- Only actual JSON numbers are summed, matching the reference's
731
+ -- \`typeof v === 'number'\`: a payload field holding "12" as a
732
+ -- string is not a metric and must not become one here.
733
+ SUM(
734
+ CASE WHEN $8::text[] IS NOT NULL
735
+ AND jsonb_typeof(f.entry->'payload' #> $8::text[]) = 'number'
736
+ THEN (f.entry->'payload' #> $8::text[])::text::numeric
737
+ END
738
+ )
739
+ FROM filtered f, step
740
+ WHERE f.started_at > 0
741
+ GROUP BY 4`;
742
+ const res = await this.db.query(sql, params);
743
+ const rows = res.rows;
744
+ const tally = /* @__PURE__ */ __name((kind) => rows.filter((r) => r.kind === kind && r.key != null).map((r) => ({
745
+ key: r.key,
746
+ count: Number(r.cnt)
747
+ })).sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)), "tally");
748
+ return {
749
+ byEntryCollateral: tally("entry").map((x) => ({
750
+ name: x.key,
751
+ count: x.count
752
+ })),
753
+ byStatus: tally("status").map((x) => ({
754
+ status: x.key,
755
+ count: x.count
756
+ })),
757
+ byNeuron: tally("neuron").map((x) => ({
758
+ name: x.key,
759
+ count: x.count
760
+ })),
761
+ byTime: rows.filter((r) => r.kind === "time" && r.ts_bucket != null).map((r) => ({
762
+ tsBucket: Number(r.ts_bucket),
763
+ completed: Number(r.completed),
764
+ failed: Number(r.failed),
765
+ running: Number(r.running),
766
+ // Absent rather than 0 when nothing was summed — the reference
767
+ // only sets `metric` once it has a number to put there.
768
+ ...r.metric == null ? {} : {
769
+ metric: Number(r.metric)
770
+ }
771
+ })).sort((a, b) => a.tsBucket - b.tsBucket)
772
+ };
425
773
  }
426
774
  // ── lifecycle ──
427
775
  async delete(stimulationId) {
428
- await this.ensure();
776
+ await this.schemaReady();
429
777
  const { p } = this;
430
778
  await this.tx(async (db) => {
431
- await db.query(`DELETE FROM ${p}task WHERE stimulation_attempt_id IN
779
+ await this.lockStimulation(db, stimulationId);
780
+ await db.query(`DELETE FROM ${p}stimulation_task WHERE stimulation_attempt_id IN
432
781
  (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt WHERE stimulation_id = $1)`, [
433
782
  stimulationId
434
783
  ]);
@@ -441,49 +790,93 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
441
790
  });
442
791
  }
443
792
  /**
444
- * Retention: prune stimulations not updated for `olderThanMs` (with their
445
- * attempts/tasks/frontier). A run's `updated_at` stops advancing once it
446
- * settles, so old finished runs age out while an active one stays fresh — the same
447
- * semantics as the in-memory store's `ttlMs` and the pg-boss checkpoint's
448
- * `deleteStale`. Run it on a schedule (cron). Returns the number removed.
793
+ * Retention: prune stimulations not updated for `olderThanMs`, with their
794
+ * attempts and tasks, in batches of one transaction each, then the attempts and
795
+ * tasks older than that whose parent row never arrived or is already gone; last,
796
+ * with a pool, drop the daily task partitions that ended before the window and
797
+ * create the next seven.
798
+ * Returns the number of stimulations removed.
449
799
  */
450
800
  async deleteStale(olderThanMs) {
451
- await this.ensure();
801
+ await this.schemaReady();
452
802
  const { p } = this;
453
- const params = [
454
- Math.max(0, Math.floor(olderThanMs))
455
- ];
456
- const cond = `updated_at < now() - ($1::bigint * interval '1 millisecond')`;
457
- const staleIds = `SELECT stimulation_id FROM ${p}stimulation WHERE ${cond}`;
458
- return this.tx(async (db) => {
459
- await db.query(`DELETE FROM ${p}task WHERE stimulation_attempt_id IN
460
- (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt
461
- WHERE stimulation_id IN (${staleIds}))`, params);
462
- await db.query(`DELETE FROM ${p}stimulation_attempt WHERE stimulation_id IN (${staleIds})`, params);
463
- const res = await db.query(`DELETE FROM ${p}stimulation WHERE ${cond} RETURNING stimulation_id`, params);
464
- return res.rows.length;
465
- });
803
+ const now = this.now();
804
+ const cutoff = now - Math.max(0, Math.floor(olderThanMs));
805
+ let removedCount = 0;
806
+ let after = "";
807
+ for (; ; ) {
808
+ const batch = await this.tx(async (db) => {
809
+ const stale = await db.query(`SELECT stimulation_id FROM ${p}stimulation
810
+ WHERE updated_at < $1 AND stimulation_id > $2
811
+ ORDER BY stimulation_id LIMIT $3`, [
812
+ cutoff,
813
+ after,
814
+ _CNSPostgresStimulationRepository.RETENTION_BATCH
815
+ ]);
816
+ const candidates = stale.rows.map((row) => row.stimulation_id);
817
+ if (candidates.length === 0) return {
818
+ candidates,
819
+ removed: 0
820
+ };
821
+ await db.query(`SELECT pg_advisory_xact_lock($1, hashtext($2 || id)) FROM unnest($3::text[]) AS id ORDER BY id`, [
822
+ _CNSPostgresStimulationRepository.LOCK_STIMULATION,
823
+ p,
824
+ candidates
825
+ ]);
826
+ const removed = await db.query(`DELETE FROM ${p}stimulation WHERE stimulation_id = ANY($1) AND updated_at < $2 RETURNING stimulation_id`, [
827
+ candidates,
828
+ cutoff
829
+ ]);
830
+ const ids = removed.rows.map((row) => row.stimulation_id);
831
+ await db.query(`DELETE FROM ${p}stimulation_task t
832
+ USING ${p}stimulation_attempt a
833
+ WHERE a.stimulation_attempt_id = t.stimulation_attempt_id AND a.stimulation_id = ANY($1)`, [
834
+ ids
835
+ ]);
836
+ await db.query(`DELETE FROM ${p}stimulation_attempt WHERE stimulation_id = ANY($1)`, [
837
+ ids
838
+ ]);
839
+ return {
840
+ candidates,
841
+ removed: ids.length
842
+ };
843
+ });
844
+ removedCount += batch.removed;
845
+ if (batch.candidates.length < _CNSPostgresStimulationRepository.RETENTION_BATCH) break;
846
+ after = batch.candidates[batch.candidates.length - 1];
847
+ }
848
+ let rotationFailure;
849
+ if (this.pool) {
850
+ try {
851
+ await this.taskPartitions.rotate({
852
+ transaction: /* @__PURE__ */ __name((work) => this.tx((db) => work(db)), "transaction")
853
+ }, `${p}stimulation_task`, now, cutoff);
854
+ } catch (error) {
855
+ rotationFailure = {
856
+ error
857
+ };
858
+ }
859
+ }
860
+ await this.db.query(`DELETE FROM ${p}stimulation_attempt a
861
+ WHERE a.started_at < $1
862
+ AND NOT EXISTS (SELECT 1 FROM ${p}stimulation s WHERE s.stimulation_id = a.stimulation_id)`, [
863
+ cutoff
864
+ ]);
865
+ await this.db.query(`DELETE FROM ${p}stimulation_task t
866
+ WHERE t.started_at < $1
867
+ AND NOT EXISTS (SELECT 1 FROM ${p}stimulation_attempt a WHERE a.stimulation_attempt_id = t.stimulation_attempt_id)`, [
868
+ cutoff
869
+ ]);
870
+ if (rotationFailure) throw rotationFailure.error;
871
+ return removedCount;
466
872
  }
467
873
  async close() {
468
874
  if (this.ownsPool && this.pool) await this.pool.end();
469
875
  }
470
- // ── helpers ──
471
- buildProgress(frontier) {
472
- const tasks = [
473
- ...frontier
474
- ].sort((a, b) => a.ord - b.ord).map((f) => ({
475
- neuronName: f.neuron_name,
476
- dendriteCollateralName: f.dendrite_collateral_name,
477
- ...f.input != null ? {
478
- input: f.input
479
- } : {}
480
- }));
481
- return {
482
- tasks
483
- };
484
- }
485
876
  };
486
877
  __name(_CNSPostgresStimulationRepository, "CNSPostgresStimulationRepository");
878
+ __publicField(_CNSPostgresStimulationRepository, "LOCK_STIMULATION", 7);
879
+ __publicField(_CNSPostgresStimulationRepository, "RETENTION_BATCH", 200);
487
880
  var CNSPostgresStimulationRepository = _CNSPostgresStimulationRepository;
488
881
  function rowToAttempt(a) {
489
882
  var _a;
@@ -505,7 +898,7 @@ function rowToTask(t) {
505
898
  var _a, _b;
506
899
  return {
507
900
  stimulationAttemptId: t.stimulation_attempt_id,
508
- index: num(t.index),
901
+ index: num(t.task_index),
509
902
  neuronName: t.neuron_name,
510
903
  dendriteCollateralName: t.dendrite_collateral_name,
511
904
  inputIndex: num(t.input_index),
@@ -513,29 +906,28 @@ function rowToTask(t) {
513
906
  status: t.status,
514
907
  error: (_b = t.error) != null ? _b : null,
515
908
  startedAt: num(t.started_at),
516
- duration: numOrNull(t.duration)
909
+ duration: numOrNull(t.duration_ms),
910
+ ...t.activation_context != null ? {
911
+ activationContext: t.activation_context
912
+ } : {}
517
913
  };
518
914
  }
519
915
  __name(rowToTask, "rowToTask");
520
- function groupBy(rows, key) {
521
- const m = /* @__PURE__ */ new Map();
522
- for (const r of rows) {
523
- const k = key(r);
524
- const b = m.get(k);
525
- if (b) b.push(r);
526
- else m.set(k, [
527
- r
528
- ]);
529
- }
530
- return m;
916
+ function rowToStimulation(r) {
917
+ return {
918
+ stimulationId: r.stimulation_id,
919
+ entry: r.entry,
920
+ status: r.status,
921
+ progress: r.progress,
922
+ ...r.scope_name != null ? {
923
+ scopeName: r.scope_name
924
+ } : {}
925
+ };
531
926
  }
532
- __name(groupBy, "groupBy");
927
+ __name(rowToStimulation, "rowToStimulation");
533
928
  // Annotate the CommonJS export names for ESM import in node:
534
929
  0 && (module.exports = {
535
930
  CNSPostgresStimulationRepository,
536
- DEFAULT_PREFIX,
537
- assertSafePrefix,
538
- ensureSchema,
539
- persistPostgresSchemaSql
931
+ CNSPostgresStimulationSchema
540
932
  });
541
933
  //# sourceMappingURL=index.js.map