@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.mjs CHANGED
@@ -1,15 +1,13 @@
1
1
  import {
2
- DEFAULT_PREFIX,
2
+ CNSPostgresSchemaPartitionRotation,
3
+ CNSPostgresStimulationSchema,
3
4
  __name,
4
- __publicField,
5
- assertSafePrefix,
6
- ensureSchema,
7
- persistPostgresSchemaSql
8
- } from "./chunk-5FLIKV6I.mjs";
5
+ __publicField
6
+ } from "./chunk-BPKIKGKB.mjs";
9
7
 
10
8
  // src/CNSPostgresStimulationRepository.ts
11
9
  import { Pool } from "pg";
12
- import { computeRosterFacets, encodeRosterCursor } from "@cnstra/persist";
10
+ import { encodeCNSRosterCursor, CNS_HISTOGRAM_BUCKETS } from "@cnstra/persist";
13
11
  var j = /* @__PURE__ */ __name((v) => JSON.stringify(v != null ? v : null), "j");
14
12
  var num = /* @__PURE__ */ __name((v) => Number(v), "num");
15
13
  var numOrNull = /* @__PURE__ */ __name((v) => v == null ? null : Number(v), "numOrNull");
@@ -20,8 +18,12 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
20
18
  __publicField(this, "ownsPool");
21
19
  __publicField(this, "p");
22
20
  __publicField(this, "autoMigrate");
21
+ __publicField(this, "now");
22
+ __publicField(this, "taskPartitions", new CNSPostgresSchemaPartitionRotation({
23
+ premakeDays: 7
24
+ }));
23
25
  __publicField(this, "ready");
24
- var _a, _b;
26
+ var _a, _b, _c;
25
27
  if (opts.pool) {
26
28
  this.pool = opts.pool;
27
29
  this.db = opts.pool;
@@ -38,13 +40,21 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
38
40
  } else {
39
41
  throw new Error("[@cnstra/persist-postgres] needs `connectionString`, `pool`, or `queryable`");
40
42
  }
41
- this.p = assertSafePrefix((_a = opts.tablePrefix) != null ? _a : DEFAULT_PREFIX);
43
+ this.p = CNSPostgresStimulationSchema.assertTablePrefix((_a = opts.tablePrefix) != null ? _a : CNSPostgresStimulationSchema.DEFAULT_TABLE_PREFIX);
42
44
  this.autoMigrate = (_b = opts.autoMigrate) != null ? _b : true;
45
+ if (this.autoMigrate && !this.pool) {
46
+ 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");
47
+ }
48
+ this.now = (_c = opts.now) != null ? _c : Date.now;
43
49
  }
44
- ensure() {
50
+ schemaReady() {
45
51
  var _a;
46
52
  if (!this.autoMigrate) return Promise.resolve();
47
- return (_a = this.ready) != null ? _a : this.ready = ensureSchema(this.db, this.p);
53
+ (_a = this.ready) != null ? _a : this.ready = CNSPostgresStimulationSchema.migrate(this.pool, this.p).catch((error) => {
54
+ this.ready = void 0;
55
+ throw error;
56
+ });
57
+ return this.ready;
48
58
  }
49
59
  /** Run `fn` in a transaction when a Pool is available; else best-effort inline. */
50
60
  async tx(fn) {
@@ -64,68 +74,87 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
64
74
  }
65
75
  // ── write ──
66
76
  async saveStimulation(s) {
67
- await this.ensure();
77
+ await this.schemaReady();
68
78
  const { p } = this;
79
+ const now = this.now();
69
80
  await this.tx(async (db) => {
70
- var _a;
71
- await db.query(`INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, updated_at)
72
- VALUES ($1, $2::jsonb, $3, $4, now())
81
+ var _a, _b;
82
+ await this.lockStimulation(db, s.stimulationId);
83
+ const upserted = await db.query(`INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, progress, updated_at)
84
+ VALUES ($1, $2::jsonb, $3, $4, $5::jsonb, $6)
73
85
  ON CONFLICT (stimulation_id)
74
86
  DO UPDATE SET entry = EXCLUDED.entry, status = EXCLUDED.status,
75
- scope_name = EXCLUDED.scope_name, updated_at = now()`, [
87
+ scope_name = EXCLUDED.scope_name, progress = EXCLUDED.progress, updated_at = EXCLUDED.updated_at
88
+ RETURNING (xmax = 0) AS inserted`, [
76
89
  s.stimulationId,
77
90
  j(s.entry),
78
91
  s.status,
79
- (_a = s.scopeName) != null ? _a : null
80
- ]);
81
- await db.query(`DELETE FROM ${p}stimulation_frontier WHERE stimulation_id = $1`, [
82
- s.stimulationId
92
+ (_a = s.scopeName) != null ? _a : null,
93
+ j(s.progress),
94
+ now
83
95
  ]);
84
- for (let i = 0; i < s.progress.tasks.length; i++) {
85
- const t = s.progress.tasks[i];
86
- await db.query(`INSERT INTO ${p}stimulation_frontier
87
- (stimulation_id, ord, neuron_name, dendrite_collateral_name, input)
88
- VALUES ($1, $2, $3, $4, $5::jsonb)`, [
89
- s.stimulationId,
90
- i,
91
- t.neuronName,
92
- t.dendriteCollateralName,
93
- t.input ? j(t.input) : null
94
- ]);
95
- }
96
+ if (((_b = upserted.rows[0]) == null ? void 0 : _b.inserted) !== false) await this.rollUpAttempts(db, s.stimulationId, now);
96
97
  });
97
98
  }
98
99
  async saveAttempt(a) {
99
- await this.ensure();
100
+ await this.schemaReady();
101
+ const { p } = this;
102
+ await this.tx(async (db) => {
103
+ await this.lockStimulation(db, a.stimulationId);
104
+ await db.query(`INSERT INTO ${p}stimulation_attempt
105
+ (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,
106
+ completed_at, hop_count, has_error, replay_of, entry)
107
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
108
+ ON CONFLICT (stimulation_attempt_id) DO UPDATE SET
109
+ status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,
110
+ hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,
111
+ replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`, [
112
+ a.stimulationAttemptId,
113
+ a.stimulationId,
114
+ a.attemptNumber,
115
+ a.status,
116
+ a.startedAt,
117
+ a.completedAt,
118
+ a.hopCount,
119
+ a.hasError,
120
+ a.replayOf,
121
+ j(a.entry)
122
+ ]);
123
+ await this.rollUpAttempts(db, a.stimulationId, this.now());
124
+ });
125
+ }
126
+ async lockStimulation(db, stimulationId) {
127
+ await db.query("SELECT pg_advisory_xact_lock($1, hashtext($2))", [
128
+ _CNSPostgresStimulationRepository.LOCK_STIMULATION,
129
+ this.p + stimulationId
130
+ ]);
131
+ }
132
+ async rollUpAttempts(db, stimulationId, now) {
100
133
  const { p } = this;
101
- await this.db.query(`INSERT INTO ${p}stimulation_attempt
102
- (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,
103
- completed_at, hop_count, has_error, replay_of, entry)
104
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
105
- ON CONFLICT (stimulation_attempt_id) DO UPDATE SET
106
- status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,
107
- hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,
108
- replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`, [
109
- a.stimulationAttemptId,
110
- a.stimulationId,
111
- a.attemptNumber,
112
- a.status,
113
- a.startedAt,
114
- a.completedAt,
115
- a.hopCount,
116
- a.hasError,
117
- a.replayOf,
118
- j(a.entry)
134
+ await db.query(`UPDATE ${p}stimulation s
135
+ SET started_at = COALESCE(a.started_at, 0),
136
+ hop_count = COALESCE(a.hop_count, 0),
137
+ last_completed_at = a.last_completed_at,
138
+ updated_at = GREATEST(s.updated_at, $2)
139
+ FROM (
140
+ SELECT MIN(started_at) AS started_at,
141
+ SUM(hop_count) AS hop_count,
142
+ (ARRAY_AGG(completed_at ORDER BY attempt_number DESC))[1] AS last_completed_at
143
+ FROM ${p}stimulation_attempt WHERE stimulation_id = $1
144
+ ) a
145
+ WHERE s.stimulation_id = $1`, [
146
+ stimulationId,
147
+ now
119
148
  ]);
120
149
  }
121
150
  async appendTask(t) {
122
- await this.ensure();
151
+ await this.schemaReady();
123
152
  const { p } = this;
124
- await this.db.query(`INSERT INTO ${p}task
125
- (stimulation_attempt_id, "index", neuron_name, dendrite_collateral_name,
126
- input_index, output, status, error, started_at, duration)
127
- VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)
128
- ON CONFLICT (stimulation_attempt_id, "index") DO NOTHING`, [
153
+ await this.db.query(`INSERT INTO ${p}stimulation_task
154
+ (stimulation_attempt_id, task_index, neuron_name, dendrite_collateral_name,
155
+ input_index, output, status, error, started_at, duration_ms, activation_context)
156
+ VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11::jsonb)
157
+ ON CONFLICT (stimulation_attempt_id, task_index, started_at) DO NOTHING`, [
129
158
  t.stimulationAttemptId,
130
159
  t.index,
131
160
  t.neuronName,
@@ -135,65 +164,37 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
135
164
  t.status,
136
165
  t.error,
137
166
  t.startedAt,
138
- t.duration
167
+ t.duration,
168
+ t.activationContext ? j(t.activationContext) : null
139
169
  ]);
140
170
  }
141
171
  // ── read ──
142
172
  async getStimulation(stimulationId) {
143
- await this.ensure();
173
+ await this.schemaReady();
144
174
  const { p } = this;
145
- const stimRes = await this.db.query(`SELECT entry, status, scope_name FROM ${p}stimulation WHERE stimulation_id = $1`, [
146
- stimulationId
147
- ]);
148
- const row = stimRes.rows[0];
149
- if (!row) return void 0;
150
- const frontier = await this.db.query(`SELECT ord, neuron_name, dendrite_collateral_name, input
151
- FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`, [
175
+ const res = await this.db.query(`SELECT stimulation_id, entry, status, scope_name, progress FROM ${p}stimulation WHERE stimulation_id = $1`, [
152
176
  stimulationId
153
177
  ]);
154
- return {
155
- stimulationId,
156
- entry: row.entry,
157
- status: row.status,
158
- progress: this.buildProgress(frontier.rows),
159
- ...row.scope_name != null ? {
160
- scopeName: row.scope_name
161
- } : {}
162
- };
178
+ const row = res.rows[0];
179
+ return row ? rowToStimulation(row) : void 0;
163
180
  }
164
181
  async listStimulations(filter) {
165
- var _a;
166
- await this.ensure();
182
+ var _a, _b;
183
+ await this.schemaReady();
167
184
  const { p } = this;
168
185
  const scope = (_a = filter == null ? void 0 : filter.scopeName) != null ? _a : null;
169
- const stimsRes = await this.db.query(`SELECT stimulation_id, entry, status, scope_name FROM ${p}stimulation
186
+ const since = (_b = filter == null ? void 0 : filter.since) != null ? _b : null;
187
+ const res = await this.db.query(`SELECT stimulation_id, entry, status, scope_name, progress FROM ${p}stimulation
170
188
  WHERE ($1::text IS NULL OR scope_name = $1)
189
+ AND ($2::bigint IS NULL OR updated_at >= $2)
171
190
  ORDER BY updated_at DESC`, [
172
- scope
191
+ scope,
192
+ since
173
193
  ]);
174
- const stims = stimsRes.rows;
175
- if (stims.length === 0) return [];
176
- const ids = stims.map((r) => r.stimulation_id);
177
- const frontierRes = await this.db.query(`SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input
178
- FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`, [
179
- ids
180
- ]);
181
- const frontierBy = groupBy(frontierRes.rows, (r) => r.stimulation_id);
182
- return stims.map((r) => {
183
- var _a2;
184
- return {
185
- stimulationId: r.stimulation_id,
186
- entry: r.entry,
187
- status: r.status,
188
- progress: this.buildProgress((_a2 = frontierBy.get(r.stimulation_id)) != null ? _a2 : []),
189
- ...r.scope_name != null ? {
190
- scopeName: r.scope_name
191
- } : {}
192
- };
193
- });
194
+ return res.rows.map(rowToStimulation);
194
195
  }
195
196
  async getAttempts(stimulationId) {
196
- await this.ensure();
197
+ await this.schemaReady();
197
198
  const { p } = this;
198
199
  const res = await this.db.query(`SELECT * FROM ${p}stimulation_attempt WHERE stimulation_id = $1 ORDER BY attempt_number`, [
199
200
  stimulationId
@@ -201,32 +202,92 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
201
202
  return res.rows.map(rowToAttempt);
202
203
  }
203
204
  async getTasks(stimulationAttemptId) {
204
- await this.ensure();
205
+ await this.schemaReady();
205
206
  const { p } = this;
206
- const res = await this.db.query(`SELECT * FROM ${p}task WHERE stimulation_attempt_id = $1 ORDER BY "index"`, [
207
+ const res = await this.db.query(`SELECT * FROM ${p}stimulation_task WHERE stimulation_attempt_id = $1 ORDER BY task_index`, [
207
208
  stimulationAttemptId
208
209
  ]);
209
210
  return res.rows.map(rowToTask);
210
211
  }
212
+ /**
213
+ * Attempts of many stimulations in ONE query, so a page of runs costs one
214
+ * round trip rather than one per run.
215
+ */
216
+ async getAttemptsFor(stimulationIds) {
217
+ if (stimulationIds.length === 0) return [];
218
+ await this.schemaReady();
219
+ const { p } = this;
220
+ const res = await this.db.query(`SELECT * FROM ${p}stimulation_attempt
221
+ WHERE stimulation_id = ANY($1)
222
+ ORDER BY stimulation_id, attempt_number`, [
223
+ stimulationIds
224
+ ]);
225
+ return res.rows.map(rowToAttempt);
226
+ }
227
+ /** Tasks of many attempts in ONE query — see {@link getAttemptsFor}. */
228
+ async getTasksFor(stimulationAttemptIds) {
229
+ if (stimulationAttemptIds.length === 0) return [];
230
+ await this.schemaReady();
231
+ const { p } = this;
232
+ const res = await this.db.query(`SELECT * FROM ${p}stimulation_task
233
+ WHERE stimulation_attempt_id = ANY($1)
234
+ ORDER BY stimulation_attempt_id, task_index`, [
235
+ stimulationAttemptIds
236
+ ]);
237
+ return res.rows.map(rowToTask);
238
+ }
211
239
  // ── roster query capability (filter + keyset + facets pushed into SQL) ──
212
240
  /**
213
- * Derive every filtered roster row in one CTE: `startedAt` = MIN(attempt),
214
- * `completedAt` = the latest attempt's, `hopCount` = SUM, `hasError` from
215
- * status, and the DISTINCT activated neuron names (for the neurons lens +
216
- * filter). Newest-first (`startedAt DESC, stimulationId DESC`) so the keyset
217
- * cursor is stable. `limit` is applied only when paginating (roster); facets
218
- * pull the whole filtered set.
241
+ * The CTE every roster read stands on. The roster fields are kept on the
242
+ * stimulation row by `saveAttempt` / `saveStimulation`, so the window and the
243
+ * order come from `stimulation(started_at DESC, stimulation_id)`; the total
244
+ * still counts the whole filtered set. Shared so the facet tallies are counted
245
+ * over exactly the set the roster pages through.
246
+ * The task table is read only when `withNeurons` asks for the neuron names.
247
+ * It binds `$1..$7` (the filter); the keyset cursor belongs to the roster's tail.
219
248
  */
249
+ rosterCte(withNeurons) {
250
+ const { p } = this;
251
+ const neurons = withNeurons ? `COALESCE((
252
+ SELECT ARRAY_AGG(DISTINCT t.neuron_name)
253
+ FROM ${p}stimulation_task t
254
+ JOIN ${p}stimulation_attempt a ON a.stimulation_attempt_id = t.stimulation_attempt_id
255
+ WHERE a.stimulation_id = s.stimulation_id
256
+ ), ARRAY[]::text[])` : "ARRAY[]::text[]";
257
+ return `
258
+ WITH roster AS (
259
+ SELECT s.stimulation_id, s.entry, s.status, s.scope_name, s.started_at,
260
+ CASE WHEN s.status = 'running' THEN NULL ELSE s.last_completed_at END AS completed_at,
261
+ s.hop_count,
262
+ (s.status = 'failed') AS has_error,
263
+ ${neurons} AS neurons
264
+ FROM ${p}stimulation s
265
+ WHERE ($6::bigint IS NULL OR s.started_at >= $6)
266
+ AND ($7::bigint IS NULL OR s.started_at <= $7)
267
+ AND ($1::text IS NULL OR s.scope_name = $1)
268
+ AND ($2::text IS NULL OR s.status = $2)
269
+ AND ($3::text IS NULL OR s.entry->>'collateralName' = $3)
270
+ AND ($5::text IS NULL OR strpos(lower(s.stimulation_id), $5) > 0)
271
+ ),
272
+ filtered AS (
273
+ SELECT *, COUNT(*) OVER() AS total_count FROM roster
274
+ WHERE ($4::text IS NULL OR $4 = ANY(neurons))
275
+ )`;
276
+ }
220
277
  async selectRoster(filter, opts) {
221
278
  var _a, _b, _c, _d, _e, _f;
222
- await this.ensure();
279
+ await this.schemaReady();
223
280
  const { p } = this;
224
281
  let curStarted = null;
225
282
  let curId = null;
226
283
  if (opts == null ? void 0 : opts.cursor) {
227
- const [ts, id] = Buffer.from(opts.cursor, "base64").toString("utf8").split("|");
228
- curStarted = Number(ts);
229
- curId = id != null ? id : "";
284
+ const decoded = Buffer.from(opts.cursor, "base64").toString("utf8");
285
+ const at = decoded.indexOf("|");
286
+ const started = Number(decoded.slice(0, at));
287
+ if (at >= 0 && Number.isFinite(started)) {
288
+ curStarted = started;
289
+ curId = decoded.slice(at + 1);
290
+ }
230
291
  }
231
292
  const params = [
232
293
  (_a = filter.scopeName) != null ? _a : null,
@@ -239,49 +300,27 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
239
300
  curStarted,
240
301
  curId
241
302
  ];
242
- const limitClause = (opts == null ? void 0 : opts.limit) != null ? ` LIMIT ${Math.max(0, opts.limit | 0)}` : "";
243
- const sql = `
244
- WITH agg AS (
245
- SELECT a.stimulation_id,
246
- MIN(a.started_at) AS started_at,
247
- SUM(a.hop_count) AS hop_count,
248
- (ARRAY_AGG(a.completed_at ORDER BY a.attempt_number DESC))[1] AS last_completed_at
249
- FROM ${p}stimulation_attempt a
250
- GROUP BY a.stimulation_id
251
- ),
252
- neu AS (
253
- SELECT ta.stimulation_id,
254
- ARRAY_AGG(DISTINCT t.neuron_name) AS neurons
255
- FROM ${p}task t
256
- JOIN ${p}stimulation_attempt ta
257
- ON ta.stimulation_attempt_id = t.stimulation_attempt_id
258
- GROUP BY ta.stimulation_id
259
- ),
260
- roster AS (
261
- SELECT s.stimulation_id, s.entry, s.status, s.scope_name,
262
- COALESCE(agg.started_at, 0) AS started_at,
263
- CASE WHEN s.status = 'running' THEN NULL
264
- ELSE agg.last_completed_at END AS completed_at,
265
- COALESCE(agg.hop_count, 0) AS hop_count,
266
- (s.status = 'failed') AS has_error,
267
- COALESCE(neu.neurons, ARRAY[]::text[]) AS neurons
268
- FROM ${p}stimulation s
269
- LEFT JOIN agg ON agg.stimulation_id = s.stimulation_id
270
- LEFT JOIN neu ON neu.stimulation_id = s.stimulation_id
271
- )
272
- SELECT *, COUNT(*) OVER() AS total_count FROM roster
273
- WHERE ($1::text IS NULL OR scope_name = $1)
274
- AND ($2::text IS NULL OR status = $2)
275
- AND ($3::text IS NULL OR entry->>'collateralName' = $3)
276
- AND ($4::text IS NULL OR $4 = ANY(neurons))
277
- AND ($5::text IS NULL OR lower(stimulation_id) LIKE '%'||$5||'%')
278
- AND ($6::bigint IS NULL OR started_at >= $6)
279
- AND ($7::bigint IS NULL OR started_at <= $7)
280
- AND ($8::bigint IS NULL OR (started_at, stimulation_id) < ($8, $9))
281
- ORDER BY started_at DESC, stimulation_id DESC${limitClause}`;
303
+ const limitClause = (opts == null ? void 0 : opts.limit) != null && Number.isFinite(opts.limit) ? ` LIMIT ${Math.max(0, Math.floor(opts.limit))}` : "";
304
+ const sql = `${this.rosterCte(filter.neuronName != null)}
305
+ -- The count is taken INSIDE \`filtered\`, before the keyset predicate
306
+ -- narrows the set. Counting after it made \`total\` shrink with every
307
+ -- page, while the in-memory reference reports the whole filtered set \u2014
308
+ -- and the two are contractually identical.
309
+ SELECT * FROM filtered
310
+ WHERE ($8::bigint IS NULL OR (started_at, stimulation_id COLLATE "C") < ($8, $9::text COLLATE "C"))
311
+ ORDER BY started_at DESC, stimulation_id COLLATE "C" DESC${limitClause}`;
282
312
  const res = await this.db.query(sql, params);
283
313
  const rows = res.rows;
284
- const total = rows.length ? Number(rows[0].total_count) : 0;
314
+ let total = rows.length ? Number(rows[0].total_count) : 0;
315
+ if (!rows.length && curStarted !== null) {
316
+ const countRes = await this.db.query(sql, [
317
+ ...params.slice(0, 7),
318
+ null,
319
+ null
320
+ ]);
321
+ const countRows = countRes.rows;
322
+ total = countRows.length ? Number(countRows[0].total_count) : 0;
323
+ }
285
324
  const entries = rows.map((r) => {
286
325
  var _a2, _b2;
287
326
  return {
@@ -304,26 +343,119 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
304
343
  };
305
344
  }
306
345
  async queryRoster(filter, opts) {
307
- const { entries, total } = await this.selectRoster(filter, opts);
308
- const rows = entries.map((e) => e.row);
346
+ const { entries, total } = await this.selectRoster(filter, {
347
+ ...opts,
348
+ limit: opts.limit + 1
349
+ });
350
+ const hasMore = entries.length > opts.limit;
351
+ const rows = entries.slice(0, opts.limit).map((e) => e.row);
309
352
  const lastRow = rows[rows.length - 1];
310
- const hasMore = rows.length >= opts.limit && rows.length < total;
311
353
  return {
312
354
  rows,
313
- nextCursor: hasMore && lastRow ? encodeRosterCursor(lastRow) : null,
355
+ nextCursor: hasMore && lastRow ? encodeCNSRosterCursor(lastRow) : null,
314
356
  total
315
357
  };
316
358
  }
317
359
  async queryFacets(filter, opts) {
318
- const { entries } = await this.selectRoster(filter);
319
- return computeRosterFacets(entries, opts);
360
+ var _a, _b, _c, _d, _e, _f;
361
+ await this.schemaReady();
362
+ const params = [
363
+ (_a = filter.scopeName) != null ? _a : null,
364
+ (_b = filter.status) != null ? _b : null,
365
+ (_c = filter.entryCollateralName) != null ? _c : null,
366
+ (_d = filter.neuronName) != null ? _d : null,
367
+ filter.text ? filter.text.toLowerCase() : null,
368
+ (_e = filter.since) != null ? _e : null,
369
+ (_f = filter.until) != null ? _f : null,
370
+ // The metric path as a jsonb path array (`a.b` → {a,b}); null when the
371
+ // chart is not summing anything.
372
+ (opts == null ? void 0 : opts.metricPath) ? opts.metricPath.split(".") : null
373
+ ];
374
+ const sql = `${this.rosterCte(true)},
375
+ -- The histogram's bucket width is derived from the set's own span, the
376
+ -- same ${CNS_HISTOGRAM_BUCKETS}-bucket rule the reference implementation
377
+ -- uses. Computed here so the whole thing stays one round trip.
378
+ bounds AS (
379
+ SELECT MIN(started_at) AS mn, MAX(started_at) AS mx FROM filtered WHERE started_at > 0
380
+ ),
381
+ step AS (
382
+ SELECT mn,
383
+ GREATEST(1, CEIL((mx - mn)::numeric / ${CNS_HISTOGRAM_BUCKETS})::bigint) AS size
384
+ FROM bounds
385
+ )
386
+ SELECT 'entry' AS kind, entry->>'collateralName' AS key,
387
+ COUNT(*)::bigint AS cnt,
388
+ NULL::bigint AS ts_bucket, NULL::bigint AS completed,
389
+ NULL::bigint AS failed, NULL::bigint AS running,
390
+ NULL::numeric AS metric
391
+ FROM filtered GROUP BY 2
392
+ UNION ALL
393
+ SELECT 'status', status, COUNT(*)::bigint,
394
+ NULL, NULL, NULL, NULL, NULL
395
+ FROM filtered GROUP BY 2
396
+ UNION ALL
397
+ -- One row per (run, neuron): the neuron rail counts runs that touched
398
+ -- a neuron, which is what UNNEST over the per-run DISTINCT array gives.
399
+ SELECT 'neuron', n, COUNT(*)::bigint,
400
+ NULL, NULL, NULL, NULL, NULL
401
+ FROM filtered, UNNEST(neurons) AS n GROUP BY 2
402
+ UNION ALL
403
+ SELECT 'time', NULL, NULL,
404
+ (step.mn + ((f.started_at - step.mn) / step.size) * step.size)::bigint,
405
+ COUNT(*) FILTER (WHERE f.status = 'completed')::bigint,
406
+ COUNT(*) FILTER (WHERE f.status = 'failed')::bigint,
407
+ COUNT(*) FILTER (WHERE f.status NOT IN ('completed', 'failed'))::bigint,
408
+ -- Only actual JSON numbers are summed, matching the reference's
409
+ -- \`typeof v === 'number'\`: a payload field holding "12" as a
410
+ -- string is not a metric and must not become one here.
411
+ SUM(
412
+ CASE WHEN $8::text[] IS NOT NULL
413
+ AND jsonb_typeof(f.entry->'payload' #> $8::text[]) = 'number'
414
+ THEN (f.entry->'payload' #> $8::text[])::text::numeric
415
+ END
416
+ )
417
+ FROM filtered f, step
418
+ WHERE f.started_at > 0
419
+ GROUP BY 4`;
420
+ const res = await this.db.query(sql, params);
421
+ const rows = res.rows;
422
+ const tally = /* @__PURE__ */ __name((kind) => rows.filter((r) => r.kind === kind && r.key != null).map((r) => ({
423
+ key: r.key,
424
+ count: Number(r.cnt)
425
+ })).sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)), "tally");
426
+ return {
427
+ byEntryCollateral: tally("entry").map((x) => ({
428
+ name: x.key,
429
+ count: x.count
430
+ })),
431
+ byStatus: tally("status").map((x) => ({
432
+ status: x.key,
433
+ count: x.count
434
+ })),
435
+ byNeuron: tally("neuron").map((x) => ({
436
+ name: x.key,
437
+ count: x.count
438
+ })),
439
+ byTime: rows.filter((r) => r.kind === "time" && r.ts_bucket != null).map((r) => ({
440
+ tsBucket: Number(r.ts_bucket),
441
+ completed: Number(r.completed),
442
+ failed: Number(r.failed),
443
+ running: Number(r.running),
444
+ // Absent rather than 0 when nothing was summed — the reference
445
+ // only sets `metric` once it has a number to put there.
446
+ ...r.metric == null ? {} : {
447
+ metric: Number(r.metric)
448
+ }
449
+ })).sort((a, b) => a.tsBucket - b.tsBucket)
450
+ };
320
451
  }
321
452
  // ── lifecycle ──
322
453
  async delete(stimulationId) {
323
- await this.ensure();
454
+ await this.schemaReady();
324
455
  const { p } = this;
325
456
  await this.tx(async (db) => {
326
- await db.query(`DELETE FROM ${p}task WHERE stimulation_attempt_id IN
457
+ await this.lockStimulation(db, stimulationId);
458
+ await db.query(`DELETE FROM ${p}stimulation_task WHERE stimulation_attempt_id IN
327
459
  (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt WHERE stimulation_id = $1)`, [
328
460
  stimulationId
329
461
  ]);
@@ -336,49 +468,93 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
336
468
  });
337
469
  }
338
470
  /**
339
- * Retention: prune stimulations not updated for `olderThanMs` (with their
340
- * attempts/tasks/frontier). A run's `updated_at` stops advancing once it
341
- * settles, so old finished runs age out while an active one stays fresh — the same
342
- * semantics as the in-memory store's `ttlMs` and the pg-boss checkpoint's
343
- * `deleteStale`. Run it on a schedule (cron). Returns the number removed.
471
+ * Retention: prune stimulations not updated for `olderThanMs`, with their
472
+ * attempts and tasks, in batches of one transaction each, then the attempts and
473
+ * tasks older than that whose parent row never arrived or is already gone; last,
474
+ * with a pool, drop the daily task partitions that ended before the window and
475
+ * create the next seven.
476
+ * Returns the number of stimulations removed.
344
477
  */
345
478
  async deleteStale(olderThanMs) {
346
- await this.ensure();
479
+ await this.schemaReady();
347
480
  const { p } = this;
348
- const params = [
349
- Math.max(0, Math.floor(olderThanMs))
350
- ];
351
- const cond = `updated_at < now() - ($1::bigint * interval '1 millisecond')`;
352
- const staleIds = `SELECT stimulation_id FROM ${p}stimulation WHERE ${cond}`;
353
- return this.tx(async (db) => {
354
- await db.query(`DELETE FROM ${p}task WHERE stimulation_attempt_id IN
355
- (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt
356
- WHERE stimulation_id IN (${staleIds}))`, params);
357
- await db.query(`DELETE FROM ${p}stimulation_attempt WHERE stimulation_id IN (${staleIds})`, params);
358
- const res = await db.query(`DELETE FROM ${p}stimulation WHERE ${cond} RETURNING stimulation_id`, params);
359
- return res.rows.length;
360
- });
481
+ const now = this.now();
482
+ const cutoff = now - Math.max(0, Math.floor(olderThanMs));
483
+ let removedCount = 0;
484
+ let after = "";
485
+ for (; ; ) {
486
+ const batch = await this.tx(async (db) => {
487
+ const stale = await db.query(`SELECT stimulation_id FROM ${p}stimulation
488
+ WHERE updated_at < $1 AND stimulation_id > $2
489
+ ORDER BY stimulation_id LIMIT $3`, [
490
+ cutoff,
491
+ after,
492
+ _CNSPostgresStimulationRepository.RETENTION_BATCH
493
+ ]);
494
+ const candidates = stale.rows.map((row) => row.stimulation_id);
495
+ if (candidates.length === 0) return {
496
+ candidates,
497
+ removed: 0
498
+ };
499
+ await db.query(`SELECT pg_advisory_xact_lock($1, hashtext($2 || id)) FROM unnest($3::text[]) AS id ORDER BY id`, [
500
+ _CNSPostgresStimulationRepository.LOCK_STIMULATION,
501
+ p,
502
+ candidates
503
+ ]);
504
+ const removed = await db.query(`DELETE FROM ${p}stimulation WHERE stimulation_id = ANY($1) AND updated_at < $2 RETURNING stimulation_id`, [
505
+ candidates,
506
+ cutoff
507
+ ]);
508
+ const ids = removed.rows.map((row) => row.stimulation_id);
509
+ await db.query(`DELETE FROM ${p}stimulation_task t
510
+ USING ${p}stimulation_attempt a
511
+ WHERE a.stimulation_attempt_id = t.stimulation_attempt_id AND a.stimulation_id = ANY($1)`, [
512
+ ids
513
+ ]);
514
+ await db.query(`DELETE FROM ${p}stimulation_attempt WHERE stimulation_id = ANY($1)`, [
515
+ ids
516
+ ]);
517
+ return {
518
+ candidates,
519
+ removed: ids.length
520
+ };
521
+ });
522
+ removedCount += batch.removed;
523
+ if (batch.candidates.length < _CNSPostgresStimulationRepository.RETENTION_BATCH) break;
524
+ after = batch.candidates[batch.candidates.length - 1];
525
+ }
526
+ let rotationFailure;
527
+ if (this.pool) {
528
+ try {
529
+ await this.taskPartitions.rotate({
530
+ transaction: /* @__PURE__ */ __name((work) => this.tx((db) => work(db)), "transaction")
531
+ }, `${p}stimulation_task`, now, cutoff);
532
+ } catch (error) {
533
+ rotationFailure = {
534
+ error
535
+ };
536
+ }
537
+ }
538
+ await this.db.query(`DELETE FROM ${p}stimulation_attempt a
539
+ WHERE a.started_at < $1
540
+ AND NOT EXISTS (SELECT 1 FROM ${p}stimulation s WHERE s.stimulation_id = a.stimulation_id)`, [
541
+ cutoff
542
+ ]);
543
+ await this.db.query(`DELETE FROM ${p}stimulation_task t
544
+ WHERE t.started_at < $1
545
+ AND NOT EXISTS (SELECT 1 FROM ${p}stimulation_attempt a WHERE a.stimulation_attempt_id = t.stimulation_attempt_id)`, [
546
+ cutoff
547
+ ]);
548
+ if (rotationFailure) throw rotationFailure.error;
549
+ return removedCount;
361
550
  }
362
551
  async close() {
363
552
  if (this.ownsPool && this.pool) await this.pool.end();
364
553
  }
365
- // ── helpers ──
366
- buildProgress(frontier) {
367
- const tasks = [
368
- ...frontier
369
- ].sort((a, b) => a.ord - b.ord).map((f) => ({
370
- neuronName: f.neuron_name,
371
- dendriteCollateralName: f.dendrite_collateral_name,
372
- ...f.input != null ? {
373
- input: f.input
374
- } : {}
375
- }));
376
- return {
377
- tasks
378
- };
379
- }
380
554
  };
381
555
  __name(_CNSPostgresStimulationRepository, "CNSPostgresStimulationRepository");
556
+ __publicField(_CNSPostgresStimulationRepository, "LOCK_STIMULATION", 7);
557
+ __publicField(_CNSPostgresStimulationRepository, "RETENTION_BATCH", 200);
382
558
  var CNSPostgresStimulationRepository = _CNSPostgresStimulationRepository;
383
559
  function rowToAttempt(a) {
384
560
  var _a;
@@ -400,7 +576,7 @@ function rowToTask(t) {
400
576
  var _a, _b;
401
577
  return {
402
578
  stimulationAttemptId: t.stimulation_attempt_id,
403
- index: num(t.index),
579
+ index: num(t.task_index),
404
580
  neuronName: t.neuron_name,
405
581
  dendriteCollateralName: t.dendrite_collateral_name,
406
582
  inputIndex: num(t.input_index),
@@ -408,28 +584,27 @@ function rowToTask(t) {
408
584
  status: t.status,
409
585
  error: (_b = t.error) != null ? _b : null,
410
586
  startedAt: num(t.started_at),
411
- duration: numOrNull(t.duration)
587
+ duration: numOrNull(t.duration_ms),
588
+ ...t.activation_context != null ? {
589
+ activationContext: t.activation_context
590
+ } : {}
412
591
  };
413
592
  }
414
593
  __name(rowToTask, "rowToTask");
415
- function groupBy(rows, key) {
416
- const m = /* @__PURE__ */ new Map();
417
- for (const r of rows) {
418
- const k = key(r);
419
- const b = m.get(k);
420
- if (b) b.push(r);
421
- else m.set(k, [
422
- r
423
- ]);
424
- }
425
- return m;
594
+ function rowToStimulation(r) {
595
+ return {
596
+ stimulationId: r.stimulation_id,
597
+ entry: r.entry,
598
+ status: r.status,
599
+ progress: r.progress,
600
+ ...r.scope_name != null ? {
601
+ scopeName: r.scope_name
602
+ } : {}
603
+ };
426
604
  }
427
- __name(groupBy, "groupBy");
605
+ __name(rowToStimulation, "rowToStimulation");
428
606
  export {
429
607
  CNSPostgresStimulationRepository,
430
- DEFAULT_PREFIX,
431
- assertSafePrefix,
432
- ensureSchema,
433
- persistPostgresSchemaSql
608
+ CNSPostgresStimulationSchema
434
609
  };
435
610
  //# sourceMappingURL=index.mjs.map