@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/README.md +60 -20
- package/dist/chunk-BPKIKGKB.mjs +310 -0
- package/dist/chunk-BPKIKGKB.mjs.map +1 -0
- package/dist/cli.js +277 -57
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +6 -7
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +54 -51
- package/dist/index.d.ts +54 -51
- package/dist/index.js +656 -264
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +388 -213
- package/dist/index.mjs.map +1 -1
- package/package.json +14 -7
- package/dist/chunk-5FLIKV6I.mjs +0 -94
- package/dist/chunk-5FLIKV6I.mjs.map +0 -1
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
|
-
|
|
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/
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
updated_at
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
CREATE
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
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
|
-
|
|
372
|
+
schemaReady() {
|
|
150
373
|
var _a;
|
|
151
374
|
if (!this.autoMigrate) return Promise.resolve();
|
|
152
|
-
|
|
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.
|
|
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
|
|
177
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
473
|
+
await this.schemaReady();
|
|
228
474
|
const { p } = this;
|
|
229
|
-
await this.db.query(`INSERT INTO ${p}
|
|
230
|
-
(stimulation_attempt_id,
|
|
231
|
-
input_index, output, status, error, started_at,
|
|
232
|
-
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)
|
|
233
|
-
ON CONFLICT (stimulation_attempt_id,
|
|
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.
|
|
495
|
+
await this.schemaReady();
|
|
249
496
|
const { p } = this;
|
|
250
|
-
const
|
|
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 =
|
|
254
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
527
|
+
await this.schemaReady();
|
|
310
528
|
const { p } = this;
|
|
311
|
-
const res = await this.db.query(`SELECT * FROM ${p}
|
|
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
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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.
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
348
|
-
const sql =
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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,
|
|
413
|
-
|
|
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.
|
|
677
|
+
nextCursor: hasMore && lastRow ? (0, import_persist.encodeCNSRosterCursor)(lastRow) : null,
|
|
419
678
|
total
|
|
420
679
|
};
|
|
421
680
|
}
|
|
422
681
|
async queryFacets(filter, opts) {
|
|
423
|
-
|
|
424
|
-
|
|
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.
|
|
776
|
+
await this.schemaReady();
|
|
429
777
|
const { p } = this;
|
|
430
778
|
await this.tx(async (db) => {
|
|
431
|
-
await
|
|
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
|
|
445
|
-
* attempts
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
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.
|
|
801
|
+
await this.schemaReady();
|
|
452
802
|
const { p } = this;
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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.
|
|
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.
|
|
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
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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(
|
|
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
|
-
|
|
537
|
-
assertSafePrefix,
|
|
538
|
-
ensureSchema,
|
|
539
|
-
persistPostgresSchemaSql
|
|
931
|
+
CNSPostgresStimulationSchema
|
|
540
932
|
});
|
|
541
933
|
//# sourceMappingURL=index.js.map
|