@cnstra/persist-postgres 0.1.0 → 0.3.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 +2 -2
- package/dist/{chunk-U4IKAHS4.mjs → chunk-5FLIKV6I.mjs} +5 -8
- package/dist/chunk-5FLIKV6I.mjs.map +1 -0
- package/dist/cli.js +4 -7
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +39 -17
- package/dist/index.d.ts +39 -17
- package/dist/index.js +150 -44
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +147 -38
- package/dist/index.mjs.map +1 -1
- package/package.json +37 -12
- package/dist/chunk-U4IKAHS4.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> **⚠️ Experimental**: API may change.
|
|
4
4
|
|
|
5
|
-
A **Postgres relational store** for CNStra's
|
|
5
|
+
A **Postgres relational store** for CNStra's resumable run/attempt/task model —
|
|
6
6
|
implements [`ICNSStimulationRepository`](../persist) (the run roster → attempt timeline →
|
|
7
|
-
task waterfall the retry admin renders). Makes the
|
|
7
|
+
task waterfall the retry admin renders). Makes the resumable-execution history **survive a
|
|
8
8
|
process restart**, and a retried run resumes from the persisted frontier.
|
|
9
9
|
|
|
10
10
|
This is domain **#2** (run/attempt/task history), distinct from the resume **checkpoint**
|
|
@@ -35,13 +35,6 @@ CREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (
|
|
|
35
35
|
PRIMARY KEY (stimulation_id, ord)
|
|
36
36
|
);
|
|
37
37
|
|
|
38
|
-
CREATE TABLE IF NOT EXISTS ${p}stimulation_context (
|
|
39
|
-
stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,
|
|
40
|
-
neuron_name text NOT NULL,
|
|
41
|
-
value jsonb NOT NULL,
|
|
42
|
-
PRIMARY KEY (stimulation_id, neuron_name)
|
|
43
|
-
);
|
|
44
|
-
|
|
45
38
|
-- NOTE: no FK on stimulation_id \u2014 over the devtools wire the persistor's
|
|
46
39
|
-- saveStimulation and saveAttempt ride SEPARATE async batches processed
|
|
47
40
|
-- concurrently by the server, so an attempt insert can reach Postgres before its
|
|
@@ -61,6 +54,8 @@ CREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (
|
|
|
61
54
|
UNIQUE (stimulation_id, attempt_number)
|
|
62
55
|
);
|
|
63
56
|
CREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);
|
|
57
|
+
-- roster query: MIN(started_at)/keyset ordering per run.
|
|
58
|
+
CREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_started_idx ON ${p}stimulation_attempt (stimulation_id, started_at);
|
|
64
59
|
|
|
65
60
|
-- NOTE: no FK on stimulation_attempt_id \u2014 the persistor streams settled tasks
|
|
66
61
|
-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),
|
|
@@ -78,6 +73,8 @@ CREATE TABLE IF NOT EXISTS ${p}task (
|
|
|
78
73
|
duration int,
|
|
79
74
|
PRIMARY KEY (stimulation_attempt_id, "index")
|
|
80
75
|
);
|
|
76
|
+
-- roster neurons lens: DISTINCT neuron_name per attempt / neuronName filter.
|
|
77
|
+
CREATE INDEX IF NOT EXISTS ${p}task_neuron_idx ON ${p}task (neuron_name);
|
|
81
78
|
`;
|
|
82
79
|
}
|
|
83
80
|
__name(persistPostgresSchemaSql, "persistPostgresSchemaSql");
|
|
@@ -94,4 +91,4 @@ export {
|
|
|
94
91
|
persistPostgresSchemaSql,
|
|
95
92
|
ensureSchema
|
|
96
93
|
};
|
|
97
|
-
//# sourceMappingURL=chunk-
|
|
94
|
+
//# sourceMappingURL=chunk-5FLIKV6I.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/migrate.ts"],"sourcesContent":["/**\n * Schema / migrations for the CNStra stimulation/attempt/task relational store.\n *\n * The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`\n * or `ensureSchema(db)`, or let the repository auto-create it on first use. Tables\n * under a configurable prefix (default `cns_`); the stimulation's `progress` is\n * NORMALISED into `stimulation_frontier` (one row per outstanding task).\n */\n\n/** Minimal pg client surface (a `pg` Pool or Client satisfies it). */\nexport interface IPgQueryable {\n query(text: string, params?: unknown[]): Promise<{ rows: unknown[] }>;\n}\n\nexport const DEFAULT_PREFIX = 'cns_';\n\n/** Guard the table prefix (it is interpolated into DDL/DML, not parameterised). */\nexport function assertSafePrefix(prefix: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {\n throw new Error(\n `[@cnstra/persist-postgres] unsafe table prefix: ${JSON.stringify(prefix)}`\n );\n }\n return prefix;\n}\n\n/** The full CREATE TABLE + CREATE INDEX DDL (idempotent). */\nexport function persistPostgresSchemaSql(prefix: string = DEFAULT_PREFIX): string {\n const p = assertSafePrefix(prefix);\n return `\nCREATE TABLE IF NOT EXISTS ${p}stimulation (\n stimulation_id text PRIMARY KEY,\n entry jsonb NOT NULL,\n status text NOT NULL,\n scope_name text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_updated_idx ON ${p}stimulation (updated_at DESC);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_scope_idx ON ${p}stimulation (scope_name);\n\nCREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (\n stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,\n ord int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input jsonb,\n PRIMARY KEY (stimulation_id, ord)\n);\n\n-- NOTE: no FK on stimulation_id — over the devtools wire the persistor's\n-- saveStimulation and saveAttempt ride SEPARATE async batches processed\n-- concurrently by the server, so an attempt insert can reach Postgres before its\n-- stimulation row's transaction commits; a hard FK would reject it (23503). Same\n-- exposure as ${p}task; delete() removes attempts explicitly instead of cascading.\nCREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (\n stimulation_attempt_id text PRIMARY KEY,\n stimulation_id text NOT NULL,\n attempt_number int NOT NULL,\n status text NOT NULL,\n started_at bigint NOT NULL,\n completed_at bigint,\n hop_count int NOT NULL,\n has_error boolean NOT NULL,\n replay_of text,\n entry jsonb NOT NULL,\n UNIQUE (stimulation_id, attempt_number)\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);\n-- roster query: MIN(started_at)/keyset ordering per run.\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_started_idx ON ${p}stimulation_attempt (stimulation_id, started_at);\n\n-- NOTE: no FK on stimulation_attempt_id — the persistor streams settled tasks\n-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),\n-- so a hard FK would reject the insert. delete() removes tasks explicitly instead.\nCREATE TABLE IF NOT EXISTS ${p}task (\n stimulation_attempt_id text NOT NULL,\n \"index\" int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input_index int NOT NULL,\n output jsonb,\n status text NOT NULL,\n error text,\n started_at bigint NOT NULL,\n duration int,\n PRIMARY KEY (stimulation_attempt_id, \"index\")\n);\n-- roster neurons lens: DISTINCT neuron_name per attempt / neuronName filter.\nCREATE INDEX IF NOT EXISTS ${p}task_neuron_idx ON ${p}task (neuron_name);\n`;\n}\n\n/** Idempotently create all tables + indexes. Safe to run repeatedly. */\nexport async function ensureSchema(\n db: IPgQueryable,\n prefix: string = DEFAULT_PREFIX\n): Promise<void> {\n await db.query(persistPostgresSchemaSql(prefix));\n}\n"],"mappings":";;;;;;AAcO,IAAMA,iBAAiB;AAGvB,SAASC,iBAAiBC,QAAc;AAC3C,MAAI,CAAC,2BAA2BC,KAAKD,MAAAA,GAAS;AAC1C,UAAM,IAAIE,MACN,mDAAmDC,KAAKC,UAAUJ,MAAAA,CAAAA,EAAS;EAEnF;AACA,SAAOA;AACX;AAPgBD;AAUT,SAASM,yBAAyBL,SAAiBF,gBAAc;AACpE,QAAMQ,IAAIP,iBAAiBC,MAAAA;AAC3B,SAAO;6BACkBM,CAAAA;;;;;;;;6BAQAA,CAAAA,8BAA+BA,CAAAA;6BAC/BA,CAAAA,4BAA6BA,CAAAA;;6BAE7BA,CAAAA;8CACiBA,CAAAA;;;;;;;;;;;;iBAY7BA,CAAAA;6BACYA,CAAAA;;;;;;;;;;;;;6BAaAA,CAAAA,8BAA+BA,CAAAA;;6BAE/BA,CAAAA,sCAAuCA,CAAAA;;;;;6BAKvCA,CAAAA;;;;;;;;;;;;;;6BAcAA,CAAAA,sBAAuBA,CAAAA;;AAEpD;AAhEgBD;AAmEhB,eAAsBE,aAClBC,IACAR,SAAiBF,gBAAc;AAE/B,QAAMU,GAAGC,MAAMJ,yBAAyBL,MAAAA,CAAAA;AAC5C;AALsBO;","names":["DEFAULT_PREFIX","assertSafePrefix","prefix","test","Error","JSON","stringify","persistPostgresSchemaSql","p","ensureSchema","db","query"]}
|
package/dist/cli.js
CHANGED
|
@@ -37,13 +37,6 @@ CREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (
|
|
|
37
37
|
PRIMARY KEY (stimulation_id, ord)
|
|
38
38
|
);
|
|
39
39
|
|
|
40
|
-
CREATE TABLE IF NOT EXISTS ${p}stimulation_context (
|
|
41
|
-
stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,
|
|
42
|
-
neuron_name text NOT NULL,
|
|
43
|
-
value jsonb NOT NULL,
|
|
44
|
-
PRIMARY KEY (stimulation_id, neuron_name)
|
|
45
|
-
);
|
|
46
|
-
|
|
47
40
|
-- NOTE: no FK on stimulation_id \u2014 over the devtools wire the persistor's
|
|
48
41
|
-- saveStimulation and saveAttempt ride SEPARATE async batches processed
|
|
49
42
|
-- concurrently by the server, so an attempt insert can reach Postgres before its
|
|
@@ -63,6 +56,8 @@ CREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (
|
|
|
63
56
|
UNIQUE (stimulation_id, attempt_number)
|
|
64
57
|
);
|
|
65
58
|
CREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);
|
|
59
|
+
-- roster query: MIN(started_at)/keyset ordering per run.
|
|
60
|
+
CREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_started_idx ON ${p}stimulation_attempt (stimulation_id, started_at);
|
|
66
61
|
|
|
67
62
|
-- NOTE: no FK on stimulation_attempt_id \u2014 the persistor streams settled tasks
|
|
68
63
|
-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),
|
|
@@ -80,6 +75,8 @@ CREATE TABLE IF NOT EXISTS ${p}task (
|
|
|
80
75
|
duration int,
|
|
81
76
|
PRIMARY KEY (stimulation_attempt_id, "index")
|
|
82
77
|
);
|
|
78
|
+
-- roster neurons lens: DISTINCT neuron_name per attempt / neuronName filter.
|
|
79
|
+
CREATE INDEX IF NOT EXISTS ${p}task_neuron_idx ON ${p}task (neuron_name);
|
|
83
80
|
`;
|
|
84
81
|
}
|
|
85
82
|
__name(persistPostgresSchemaSql, "persistPostgresSchemaSql");
|
package/dist/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli.ts","../src/migrate.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * `cnstra-persist-postgres migrate` — create the stimulation/attempt/task tables.\n *\n * DATABASE_URL=postgres://... cnstra-persist-postgres migrate\n * CNS_TABLE_PREFIX=my_ (optional; default cns_)\n */\nimport { Pool } from 'pg';\nimport { ensureSchema, DEFAULT_PREFIX } from './migrate';\n\nasync function main(): Promise<void> {\n const cmd = process.argv[2];\n if (cmd !== 'migrate') {\n console.error('usage: cnstra-persist-postgres migrate'); // eslint-disable-line no-console\n process.exit(1);\n }\n const url = process.env.DATABASE_URL;\n if (!url) {\n console.error('DATABASE_URL is required'); // eslint-disable-line no-console\n process.exit(1);\n }\n const prefix = process.env.CNS_TABLE_PREFIX ?? DEFAULT_PREFIX;\n const pool = new Pool({ connectionString: url });\n try {\n await ensureSchema(pool, prefix);\n // eslint-disable-next-line no-console\n console.log(`✓ ensured stimulation/attempt/task tables (prefix \"${prefix}\")`);\n } finally {\n await pool.end();\n }\n}\n\nvoid main().catch(e => {\n console.error(e); // eslint-disable-line no-console\n process.exit(1);\n});\n","/**\n * Schema / migrations for the CNStra stimulation/attempt/task relational store.\n *\n * The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`\n * or `ensureSchema(db)`, or let the repository auto-create it on first use.
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts","../src/migrate.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * `cnstra-persist-postgres migrate` — create the stimulation/attempt/task tables.\n *\n * DATABASE_URL=postgres://... cnstra-persist-postgres migrate\n * CNS_TABLE_PREFIX=my_ (optional; default cns_)\n */\nimport { Pool } from 'pg';\nimport { ensureSchema, DEFAULT_PREFIX } from './migrate';\n\nasync function main(): Promise<void> {\n const cmd = process.argv[2];\n if (cmd !== 'migrate') {\n console.error('usage: cnstra-persist-postgres migrate'); // eslint-disable-line no-console\n process.exit(1);\n }\n const url = process.env.DATABASE_URL;\n if (!url) {\n console.error('DATABASE_URL is required'); // eslint-disable-line no-console\n process.exit(1);\n }\n const prefix = process.env.CNS_TABLE_PREFIX ?? DEFAULT_PREFIX;\n const pool = new Pool({ connectionString: url });\n try {\n await ensureSchema(pool, prefix);\n // eslint-disable-next-line no-console\n console.log(`✓ ensured stimulation/attempt/task tables (prefix \"${prefix}\")`);\n } finally {\n await pool.end();\n }\n}\n\nvoid main().catch(e => {\n console.error(e); // eslint-disable-line no-console\n process.exit(1);\n});\n","/**\n * Schema / migrations for the CNStra stimulation/attempt/task relational store.\n *\n * The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`\n * or `ensureSchema(db)`, or let the repository auto-create it on first use. Tables\n * under a configurable prefix (default `cns_`); the stimulation's `progress` is\n * NORMALISED into `stimulation_frontier` (one row per outstanding task).\n */\n\n/** Minimal pg client surface (a `pg` Pool or Client satisfies it). */\nexport interface IPgQueryable {\n query(text: string, params?: unknown[]): Promise<{ rows: unknown[] }>;\n}\n\nexport const DEFAULT_PREFIX = 'cns_';\n\n/** Guard the table prefix (it is interpolated into DDL/DML, not parameterised). */\nexport function assertSafePrefix(prefix: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {\n throw new Error(\n `[@cnstra/persist-postgres] unsafe table prefix: ${JSON.stringify(prefix)}`\n );\n }\n return prefix;\n}\n\n/** The full CREATE TABLE + CREATE INDEX DDL (idempotent). */\nexport function persistPostgresSchemaSql(prefix: string = DEFAULT_PREFIX): string {\n const p = assertSafePrefix(prefix);\n return `\nCREATE TABLE IF NOT EXISTS ${p}stimulation (\n stimulation_id text PRIMARY KEY,\n entry jsonb NOT NULL,\n status text NOT NULL,\n scope_name text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_updated_idx ON ${p}stimulation (updated_at DESC);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_scope_idx ON ${p}stimulation (scope_name);\n\nCREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (\n stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,\n ord int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input jsonb,\n PRIMARY KEY (stimulation_id, ord)\n);\n\n-- NOTE: no FK on stimulation_id — over the devtools wire the persistor's\n-- saveStimulation and saveAttempt ride SEPARATE async batches processed\n-- concurrently by the server, so an attempt insert can reach Postgres before its\n-- stimulation row's transaction commits; a hard FK would reject it (23503). Same\n-- exposure as ${p}task; delete() removes attempts explicitly instead of cascading.\nCREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (\n stimulation_attempt_id text PRIMARY KEY,\n stimulation_id text NOT NULL,\n attempt_number int NOT NULL,\n status text NOT NULL,\n started_at bigint NOT NULL,\n completed_at bigint,\n hop_count int NOT NULL,\n has_error boolean NOT NULL,\n replay_of text,\n entry jsonb NOT NULL,\n UNIQUE (stimulation_id, attempt_number)\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);\n-- roster query: MIN(started_at)/keyset ordering per run.\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_started_idx ON ${p}stimulation_attempt (stimulation_id, started_at);\n\n-- NOTE: no FK on stimulation_attempt_id — the persistor streams settled tasks\n-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),\n-- so a hard FK would reject the insert. delete() removes tasks explicitly instead.\nCREATE TABLE IF NOT EXISTS ${p}task (\n stimulation_attempt_id text NOT NULL,\n \"index\" int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input_index int NOT NULL,\n output jsonb,\n status text NOT NULL,\n error text,\n started_at bigint NOT NULL,\n duration int,\n PRIMARY KEY (stimulation_attempt_id, \"index\")\n);\n-- roster neurons lens: DISTINCT neuron_name per attempt / neuronName filter.\nCREATE INDEX IF NOT EXISTS ${p}task_neuron_idx ON ${p}task (neuron_name);\n`;\n}\n\n/** Idempotently create all tables + indexes. Safe to run repeatedly. */\nexport async function ensureSchema(\n db: IPgQueryable,\n prefix: string = DEFAULT_PREFIX\n): Promise<void> {\n await db.query(persistPostgresSchemaSql(prefix));\n}\n"],"mappings":";;;;;AAOA,gBAAqB;;;ACOd,IAAMA,iBAAiB;AAGvB,SAASC,iBAAiBC,QAAc;AAC3C,MAAI,CAAC,2BAA2BC,KAAKD,MAAAA,GAAS;AAC1C,UAAM,IAAIE,MACN,mDAAmDC,KAAKC,UAAUJ,MAAAA,CAAAA,EAAS;EAEnF;AACA,SAAOA;AACX;AAPgBD;AAUT,SAASM,yBAAyBL,SAAiBF,gBAAc;AACpE,QAAMQ,IAAIP,iBAAiBC,MAAAA;AAC3B,SAAO;6BACkBM,CAAAA;;;;;;;;6BAQAA,CAAAA,8BAA+BA,CAAAA;6BAC/BA,CAAAA,4BAA6BA,CAAAA;;6BAE7BA,CAAAA;8CACiBA,CAAAA;;;;;;;;;;;;iBAY7BA,CAAAA;6BACYA,CAAAA;;;;;;;;;;;;;6BAaAA,CAAAA,8BAA+BA,CAAAA;;6BAE/BA,CAAAA,sCAAuCA,CAAAA;;;;;6BAKvCA,CAAAA;;;;;;;;;;;;;;6BAcAA,CAAAA,sBAAuBA,CAAAA;;AAEpD;AAhEgBD;AAmEhB,eAAsBE,aAClBC,IACAR,SAAiBF,gBAAc;AAE/B,QAAMU,GAAGC,MAAMJ,yBAAyBL,MAAAA,CAAAA;AAC5C;AALsBO;;;ADpFtB,eAAeG,OAAAA;;AACX,QAAMC,MAAMC,QAAQC,KAAK,CAAA;AACzB,MAAIF,QAAQ,WAAW;AACnBG,YAAQC,MAAM,wCAAA;AACdH,YAAQI,KAAK,CAAA;EACjB;AACA,QAAMC,MAAML,QAAQM,IAAIC;AACxB,MAAI,CAACF,KAAK;AACNH,YAAQC,MAAM,0BAAA;AACdH,YAAQI,KAAK,CAAA;EACjB;AACA,QAAMI,UAASR,aAAQM,IAAIG,qBAAZT,YAAgCU;AAC/C,QAAMC,OAAO,IAAIC,eAAK;IAAEC,kBAAkBR;EAAI,CAAA;AAC9C,MAAI;AACA,UAAMS,aAAaH,MAAMH,MAAAA;AAEzBN,YAAQa,IAAI,2DAAsDP,MAAAA,IAAU;EAChF,UAAA;AACI,UAAMG,KAAKK,IAAG;EAClB;AACJ;AApBelB;AAsBf,KAAKA,KAAAA,EAAOmB,MAAMC,CAAAA,MAAAA;AACdhB,UAAQC,MAAMe,CAAAA;AACdlB,UAAQI,KAAK,CAAA;AACjB,CAAA;","names":["DEFAULT_PREFIX","assertSafePrefix","prefix","test","Error","JSON","stringify","persistPostgresSchemaSql","p","ensureSchema","db","query","main","cmd","process","argv","console","error","exit","url","env","DATABASE_URL","prefix","CNS_TABLE_PREFIX","DEFAULT_PREFIX","pool","Pool","connectionString","ensureSchema","log","end","catch","e"]}
|
package/dist/cli.mjs
CHANGED
package/dist/index.d.mts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { Pool } from 'pg';
|
|
2
|
-
import { ICNSStimulationRepository } from '@cnstra/persist';
|
|
3
|
-
import {
|
|
2
|
+
import { ICNSStimulationRepository, ICNSStimulationRosterQuery } from '@cnstra/persist';
|
|
3
|
+
import { TCNSStimulationState, TCNSStimulationAttempt, TCNSStimulationTask, TCNSStimulationRosterFilter, TCNSStimulationRosterPage, TCNSStimulationRosterFacets } from '@cnstra/persist-dto';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Schema / migrations for the CNStra stimulation/attempt/task relational store.
|
|
7
7
|
*
|
|
8
8
|
* The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`
|
|
9
|
-
* or `ensureSchema(db)`, or let the repository auto-create it on first use.
|
|
9
|
+
* or `ensureSchema(db)`, or let the repository auto-create it on first use. Tables
|
|
10
10
|
* under a configurable prefix (default `cns_`); the stimulation's `progress` is
|
|
11
|
-
* NORMALISED into `stimulation_frontier` (one row per outstanding task)
|
|
12
|
-
* `stimulation_context` (one row per neuron).
|
|
11
|
+
* NORMALISED into `stimulation_frontier` (one row per outstanding task).
|
|
13
12
|
*/
|
|
14
13
|
/** Minimal pg client surface (a `pg` Pool or Client satisfies it). */
|
|
15
14
|
interface IPgQueryable {
|
|
@@ -26,11 +25,10 @@ declare function persistPostgresSchemaSql(prefix?: string): string;
|
|
|
26
25
|
declare function ensureSchema(db: IPgQueryable, prefix?: string): Promise<void>;
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
|
-
* Postgres relational store for the CNStra stimulation/attempt/task
|
|
28
|
+
* Postgres relational store for the CNStra stimulation/attempt/task resumable model
|
|
30
29
|
* (`ICNSStimulationRepository`). The stimulation's `progress` is NORMALISED: the
|
|
31
|
-
* outstanding frontier into `<prefix>stimulation_frontier` (one row per task)
|
|
32
|
-
*
|
|
33
|
-
* on read.
|
|
30
|
+
* outstanding frontier into `<prefix>stimulation_frontier` (one row per task) —
|
|
31
|
+
* rebuilt into `TCNSProgress` on read.
|
|
34
32
|
*
|
|
35
33
|
* Reuses the `IPgQueryable` seam so `pg` is an optional peer: inject any pg
|
|
36
34
|
* client/pool, or pass a `connectionString` for the repo to own a Pool. Behaviourally
|
|
@@ -49,7 +47,7 @@ interface TCNSPostgresStimulationRepositoryOptions {
|
|
|
49
47
|
/** Lazily ensure the schema on first use (default true). */
|
|
50
48
|
autoMigrate?: boolean;
|
|
51
49
|
}
|
|
52
|
-
declare class CNSPostgresStimulationRepository implements ICNSStimulationRepository {
|
|
50
|
+
declare class CNSPostgresStimulationRepository implements ICNSStimulationRepository, ICNSStimulationRosterQuery {
|
|
53
51
|
private readonly db;
|
|
54
52
|
private readonly pool?;
|
|
55
53
|
private readonly ownsPool;
|
|
@@ -60,16 +58,40 @@ declare class CNSPostgresStimulationRepository implements ICNSStimulationReposit
|
|
|
60
58
|
private ensure;
|
|
61
59
|
/** Run `fn` in a transaction when a Pool is available; else best-effort inline. */
|
|
62
60
|
private tx;
|
|
63
|
-
saveStimulation(s:
|
|
64
|
-
saveAttempt(a:
|
|
65
|
-
appendTask(t:
|
|
66
|
-
getStimulation(stimulationId: string): Promise<
|
|
61
|
+
saveStimulation(s: TCNSStimulationState): Promise<void>;
|
|
62
|
+
saveAttempt(a: TCNSStimulationAttempt): Promise<void>;
|
|
63
|
+
appendTask(t: TCNSStimulationTask): Promise<void>;
|
|
64
|
+
getStimulation(stimulationId: string): Promise<TCNSStimulationState | undefined>;
|
|
67
65
|
listStimulations(filter?: {
|
|
68
66
|
scopeName?: string;
|
|
69
|
-
}): Promise<
|
|
70
|
-
getAttempts(stimulationId: string): Promise<
|
|
71
|
-
getTasks(stimulationAttemptId: string): Promise<
|
|
67
|
+
}): Promise<TCNSStimulationState[]>;
|
|
68
|
+
getAttempts(stimulationId: string): Promise<TCNSStimulationAttempt[]>;
|
|
69
|
+
getTasks(stimulationAttemptId: string): Promise<TCNSStimulationTask[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Derive every filtered roster row in one CTE: `startedAt` = MIN(attempt),
|
|
72
|
+
* `completedAt` = the latest attempt's, `hopCount` = SUM, `hasError` from
|
|
73
|
+
* status, and the DISTINCT activated neuron names (for the neurons lens +
|
|
74
|
+
* filter). Newest-first (`startedAt DESC, stimulationId DESC`) so the keyset
|
|
75
|
+
* cursor is stable. `limit` is applied only when paginating (roster); facets
|
|
76
|
+
* pull the whole filtered set.
|
|
77
|
+
*/
|
|
78
|
+
private selectRoster;
|
|
79
|
+
queryRoster(filter: TCNSStimulationRosterFilter, opts: {
|
|
80
|
+
cursor?: string;
|
|
81
|
+
limit: number;
|
|
82
|
+
}): Promise<TCNSStimulationRosterPage>;
|
|
83
|
+
queryFacets(filter: TCNSStimulationRosterFilter, opts?: {
|
|
84
|
+
metricPath?: string;
|
|
85
|
+
}): Promise<TCNSStimulationRosterFacets>;
|
|
72
86
|
delete(stimulationId: string): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Retention: prune stimulations not updated for `olderThanMs` (with their
|
|
89
|
+
* attempts/tasks/frontier). A run's `updated_at` stops advancing once it
|
|
90
|
+
* settles, so old finished runs age out while an active one stays fresh — the same
|
|
91
|
+
* semantics as the in-memory store's `ttlMs` and the pg-boss checkpoint's
|
|
92
|
+
* `deleteStale`. Run it on a schedule (cron). Returns the number removed.
|
|
93
|
+
*/
|
|
94
|
+
deleteStale(olderThanMs: number): Promise<number>;
|
|
73
95
|
close(): Promise<void>;
|
|
74
96
|
private buildProgress;
|
|
75
97
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { Pool } from 'pg';
|
|
2
|
-
import { ICNSStimulationRepository } from '@cnstra/persist';
|
|
3
|
-
import {
|
|
2
|
+
import { ICNSStimulationRepository, ICNSStimulationRosterQuery } from '@cnstra/persist';
|
|
3
|
+
import { TCNSStimulationState, TCNSStimulationAttempt, TCNSStimulationTask, TCNSStimulationRosterFilter, TCNSStimulationRosterPage, TCNSStimulationRosterFacets } from '@cnstra/persist-dto';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Schema / migrations for the CNStra stimulation/attempt/task relational store.
|
|
7
7
|
*
|
|
8
8
|
* The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`
|
|
9
|
-
* or `ensureSchema(db)`, or let the repository auto-create it on first use.
|
|
9
|
+
* or `ensureSchema(db)`, or let the repository auto-create it on first use. Tables
|
|
10
10
|
* under a configurable prefix (default `cns_`); the stimulation's `progress` is
|
|
11
|
-
* NORMALISED into `stimulation_frontier` (one row per outstanding task)
|
|
12
|
-
* `stimulation_context` (one row per neuron).
|
|
11
|
+
* NORMALISED into `stimulation_frontier` (one row per outstanding task).
|
|
13
12
|
*/
|
|
14
13
|
/** Minimal pg client surface (a `pg` Pool or Client satisfies it). */
|
|
15
14
|
interface IPgQueryable {
|
|
@@ -26,11 +25,10 @@ declare function persistPostgresSchemaSql(prefix?: string): string;
|
|
|
26
25
|
declare function ensureSchema(db: IPgQueryable, prefix?: string): Promise<void>;
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
|
-
* Postgres relational store for the CNStra stimulation/attempt/task
|
|
28
|
+
* Postgres relational store for the CNStra stimulation/attempt/task resumable model
|
|
30
29
|
* (`ICNSStimulationRepository`). The stimulation's `progress` is NORMALISED: the
|
|
31
|
-
* outstanding frontier into `<prefix>stimulation_frontier` (one row per task)
|
|
32
|
-
*
|
|
33
|
-
* on read.
|
|
30
|
+
* outstanding frontier into `<prefix>stimulation_frontier` (one row per task) —
|
|
31
|
+
* rebuilt into `TCNSProgress` on read.
|
|
34
32
|
*
|
|
35
33
|
* Reuses the `IPgQueryable` seam so `pg` is an optional peer: inject any pg
|
|
36
34
|
* client/pool, or pass a `connectionString` for the repo to own a Pool. Behaviourally
|
|
@@ -49,7 +47,7 @@ interface TCNSPostgresStimulationRepositoryOptions {
|
|
|
49
47
|
/** Lazily ensure the schema on first use (default true). */
|
|
50
48
|
autoMigrate?: boolean;
|
|
51
49
|
}
|
|
52
|
-
declare class CNSPostgresStimulationRepository implements ICNSStimulationRepository {
|
|
50
|
+
declare class CNSPostgresStimulationRepository implements ICNSStimulationRepository, ICNSStimulationRosterQuery {
|
|
53
51
|
private readonly db;
|
|
54
52
|
private readonly pool?;
|
|
55
53
|
private readonly ownsPool;
|
|
@@ -60,16 +58,40 @@ declare class CNSPostgresStimulationRepository implements ICNSStimulationReposit
|
|
|
60
58
|
private ensure;
|
|
61
59
|
/** Run `fn` in a transaction when a Pool is available; else best-effort inline. */
|
|
62
60
|
private tx;
|
|
63
|
-
saveStimulation(s:
|
|
64
|
-
saveAttempt(a:
|
|
65
|
-
appendTask(t:
|
|
66
|
-
getStimulation(stimulationId: string): Promise<
|
|
61
|
+
saveStimulation(s: TCNSStimulationState): Promise<void>;
|
|
62
|
+
saveAttempt(a: TCNSStimulationAttempt): Promise<void>;
|
|
63
|
+
appendTask(t: TCNSStimulationTask): Promise<void>;
|
|
64
|
+
getStimulation(stimulationId: string): Promise<TCNSStimulationState | undefined>;
|
|
67
65
|
listStimulations(filter?: {
|
|
68
66
|
scopeName?: string;
|
|
69
|
-
}): Promise<
|
|
70
|
-
getAttempts(stimulationId: string): Promise<
|
|
71
|
-
getTasks(stimulationAttemptId: string): Promise<
|
|
67
|
+
}): Promise<TCNSStimulationState[]>;
|
|
68
|
+
getAttempts(stimulationId: string): Promise<TCNSStimulationAttempt[]>;
|
|
69
|
+
getTasks(stimulationAttemptId: string): Promise<TCNSStimulationTask[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Derive every filtered roster row in one CTE: `startedAt` = MIN(attempt),
|
|
72
|
+
* `completedAt` = the latest attempt's, `hopCount` = SUM, `hasError` from
|
|
73
|
+
* status, and the DISTINCT activated neuron names (for the neurons lens +
|
|
74
|
+
* filter). Newest-first (`startedAt DESC, stimulationId DESC`) so the keyset
|
|
75
|
+
* cursor is stable. `limit` is applied only when paginating (roster); facets
|
|
76
|
+
* pull the whole filtered set.
|
|
77
|
+
*/
|
|
78
|
+
private selectRoster;
|
|
79
|
+
queryRoster(filter: TCNSStimulationRosterFilter, opts: {
|
|
80
|
+
cursor?: string;
|
|
81
|
+
limit: number;
|
|
82
|
+
}): Promise<TCNSStimulationRosterPage>;
|
|
83
|
+
queryFacets(filter: TCNSStimulationRosterFilter, opts?: {
|
|
84
|
+
metricPath?: string;
|
|
85
|
+
}): Promise<TCNSStimulationRosterFacets>;
|
|
72
86
|
delete(stimulationId: string): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Retention: prune stimulations not updated for `olderThanMs` (with their
|
|
89
|
+
* attempts/tasks/frontier). A run's `updated_at` stops advancing once it
|
|
90
|
+
* settles, so old finished runs age out while an active one stays fresh — the same
|
|
91
|
+
* semantics as the in-memory store's `ttlMs` and the pg-boss checkpoint's
|
|
92
|
+
* `deleteStale`. Run it on a schedule (cron). Returns the number removed.
|
|
93
|
+
*/
|
|
94
|
+
deleteStale(olderThanMs: number): Promise<number>;
|
|
73
95
|
close(): Promise<void>;
|
|
74
96
|
private buildProgress;
|
|
75
97
|
}
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
32
32
|
|
|
33
33
|
// src/CNSPostgresStimulationRepository.ts
|
|
34
34
|
var import_pg = require("pg");
|
|
35
|
+
var import_persist = require("@cnstra/persist");
|
|
35
36
|
|
|
36
37
|
// src/migrate.ts
|
|
37
38
|
var DEFAULT_PREFIX = "cns_";
|
|
@@ -65,13 +66,6 @@ CREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (
|
|
|
65
66
|
PRIMARY KEY (stimulation_id, ord)
|
|
66
67
|
);
|
|
67
68
|
|
|
68
|
-
CREATE TABLE IF NOT EXISTS ${p}stimulation_context (
|
|
69
|
-
stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,
|
|
70
|
-
neuron_name text NOT NULL,
|
|
71
|
-
value jsonb NOT NULL,
|
|
72
|
-
PRIMARY KEY (stimulation_id, neuron_name)
|
|
73
|
-
);
|
|
74
|
-
|
|
75
69
|
-- NOTE: no FK on stimulation_id \u2014 over the devtools wire the persistor's
|
|
76
70
|
-- saveStimulation and saveAttempt ride SEPARATE async batches processed
|
|
77
71
|
-- concurrently by the server, so an attempt insert can reach Postgres before its
|
|
@@ -91,6 +85,8 @@ CREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (
|
|
|
91
85
|
UNIQUE (stimulation_id, attempt_number)
|
|
92
86
|
);
|
|
93
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);
|
|
94
90
|
|
|
95
91
|
-- NOTE: no FK on stimulation_attempt_id \u2014 the persistor streams settled tasks
|
|
96
92
|
-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),
|
|
@@ -108,6 +104,8 @@ CREATE TABLE IF NOT EXISTS ${p}task (
|
|
|
108
104
|
duration int,
|
|
109
105
|
PRIMARY KEY (stimulation_attempt_id, "index")
|
|
110
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);
|
|
111
109
|
`;
|
|
112
110
|
}
|
|
113
111
|
__name(persistPostgresSchemaSql, "persistPostgresSchemaSql");
|
|
@@ -200,17 +198,6 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
200
198
|
t.input ? j(t.input) : null
|
|
201
199
|
]);
|
|
202
200
|
}
|
|
203
|
-
await db.query(`DELETE FROM ${p}stimulation_context WHERE stimulation_id = $1`, [
|
|
204
|
-
s.stimulationId
|
|
205
|
-
]);
|
|
206
|
-
for (const [neuronName, value] of Object.entries(s.progress.context)) {
|
|
207
|
-
await db.query(`INSERT INTO ${p}stimulation_context (stimulation_id, neuron_name, value)
|
|
208
|
-
VALUES ($1, $2, $3::jsonb)`, [
|
|
209
|
-
s.stimulationId,
|
|
210
|
-
neuronName,
|
|
211
|
-
j(value)
|
|
212
|
-
]);
|
|
213
|
-
}
|
|
214
201
|
});
|
|
215
202
|
}
|
|
216
203
|
async saveAttempt(a) {
|
|
@@ -265,20 +252,15 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
265
252
|
]);
|
|
266
253
|
const row = stimRes.rows[0];
|
|
267
254
|
if (!row) return void 0;
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
stimulationId
|
|
272
|
-
]),
|
|
273
|
-
this.db.query(`SELECT neuron_name, value FROM ${p}stimulation_context WHERE stimulation_id = $1`, [
|
|
274
|
-
stimulationId
|
|
275
|
-
])
|
|
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
|
|
276
258
|
]);
|
|
277
259
|
return {
|
|
278
260
|
stimulationId,
|
|
279
261
|
entry: row.entry,
|
|
280
262
|
status: row.status,
|
|
281
|
-
progress: this.buildProgress(frontier.rows
|
|
263
|
+
progress: this.buildProgress(frontier.rows),
|
|
282
264
|
...row.scope_name != null ? {
|
|
283
265
|
scopeName: row.scope_name
|
|
284
266
|
} : {}
|
|
@@ -297,25 +279,18 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
297
279
|
const stims = stimsRes.rows;
|
|
298
280
|
if (stims.length === 0) return [];
|
|
299
281
|
const ids = stims.map((r) => r.stimulation_id);
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
ids
|
|
304
|
-
]),
|
|
305
|
-
this.db.query(`SELECT stimulation_id, neuron_name, value
|
|
306
|
-
FROM ${p}stimulation_context WHERE stimulation_id = ANY($1)`, [
|
|
307
|
-
ids
|
|
308
|
-
])
|
|
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
|
|
309
285
|
]);
|
|
310
286
|
const frontierBy = groupBy(frontierRes.rows, (r) => r.stimulation_id);
|
|
311
|
-
const contextBy = groupBy(contextRes.rows, (r) => r.stimulation_id);
|
|
312
287
|
return stims.map((r) => {
|
|
313
|
-
var _a2
|
|
288
|
+
var _a2;
|
|
314
289
|
return {
|
|
315
290
|
stimulationId: r.stimulation_id,
|
|
316
291
|
entry: r.entry,
|
|
317
292
|
status: r.status,
|
|
318
|
-
progress: this.buildProgress((_a2 = frontierBy.get(r.stimulation_id)) != null ? _a2 : []
|
|
293
|
+
progress: this.buildProgress((_a2 = frontierBy.get(r.stimulation_id)) != null ? _a2 : []),
|
|
319
294
|
...r.scope_name != null ? {
|
|
320
295
|
scopeName: r.scope_name
|
|
321
296
|
} : {}
|
|
@@ -338,6 +313,116 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
338
313
|
]);
|
|
339
314
|
return res.rows.map(rowToTask);
|
|
340
315
|
}
|
|
316
|
+
// ── roster query capability (filter + keyset + facets pushed into SQL) ──
|
|
317
|
+
/**
|
|
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.
|
|
324
|
+
*/
|
|
325
|
+
async selectRoster(filter, opts) {
|
|
326
|
+
var _a, _b, _c, _d, _e, _f;
|
|
327
|
+
await this.ensure();
|
|
328
|
+
const { p } = this;
|
|
329
|
+
let curStarted = null;
|
|
330
|
+
let curId = null;
|
|
331
|
+
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 : "";
|
|
335
|
+
}
|
|
336
|
+
const params = [
|
|
337
|
+
(_a = filter.scopeName) != null ? _a : null,
|
|
338
|
+
(_b = filter.status) != null ? _b : null,
|
|
339
|
+
(_c = filter.entryCollateralName) != null ? _c : null,
|
|
340
|
+
(_d = filter.neuronName) != null ? _d : null,
|
|
341
|
+
filter.text ? filter.text.toLowerCase() : null,
|
|
342
|
+
(_e = filter.since) != null ? _e : null,
|
|
343
|
+
(_f = filter.until) != null ? _f : null,
|
|
344
|
+
curStarted,
|
|
345
|
+
curId
|
|
346
|
+
];
|
|
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}`;
|
|
387
|
+
const res = await this.db.query(sql, params);
|
|
388
|
+
const rows = res.rows;
|
|
389
|
+
const total = rows.length ? Number(rows[0].total_count) : 0;
|
|
390
|
+
const entries = rows.map((r) => {
|
|
391
|
+
var _a2, _b2;
|
|
392
|
+
return {
|
|
393
|
+
row: {
|
|
394
|
+
stimulationId: r.stimulation_id,
|
|
395
|
+
scopeName: (_a2 = r.scope_name) != null ? _a2 : void 0,
|
|
396
|
+
entry: r.entry,
|
|
397
|
+
status: r.status,
|
|
398
|
+
startedAt: Number(r.started_at),
|
|
399
|
+
completedAt: r.completed_at == null ? null : Number(r.completed_at),
|
|
400
|
+
hopCount: Number(r.hop_count),
|
|
401
|
+
hasError: r.has_error
|
|
402
|
+
},
|
|
403
|
+
neurons: (_b2 = r.neurons) != null ? _b2 : []
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
return {
|
|
407
|
+
entries,
|
|
408
|
+
total
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async queryRoster(filter, opts) {
|
|
412
|
+
const { entries, total } = await this.selectRoster(filter, opts);
|
|
413
|
+
const rows = entries.map((e) => e.row);
|
|
414
|
+
const lastRow = rows[rows.length - 1];
|
|
415
|
+
const hasMore = rows.length >= opts.limit && rows.length < total;
|
|
416
|
+
return {
|
|
417
|
+
rows,
|
|
418
|
+
nextCursor: hasMore && lastRow ? (0, import_persist.encodeRosterCursor)(lastRow) : null,
|
|
419
|
+
total
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
async queryFacets(filter, opts) {
|
|
423
|
+
const { entries } = await this.selectRoster(filter);
|
|
424
|
+
return (0, import_persist.computeRosterFacets)(entries, opts);
|
|
425
|
+
}
|
|
341
426
|
// ── lifecycle ──
|
|
342
427
|
async delete(stimulationId) {
|
|
343
428
|
await this.ensure();
|
|
@@ -355,11 +440,35 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
355
440
|
]);
|
|
356
441
|
});
|
|
357
442
|
}
|
|
443
|
+
/**
|
|
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.
|
|
449
|
+
*/
|
|
450
|
+
async deleteStale(olderThanMs) {
|
|
451
|
+
await this.ensure();
|
|
452
|
+
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
|
+
});
|
|
466
|
+
}
|
|
358
467
|
async close() {
|
|
359
468
|
if (this.ownsPool && this.pool) await this.pool.end();
|
|
360
469
|
}
|
|
361
470
|
// ── helpers ──
|
|
362
|
-
buildProgress(frontier
|
|
471
|
+
buildProgress(frontier) {
|
|
363
472
|
const tasks = [
|
|
364
473
|
...frontier
|
|
365
474
|
].sort((a, b) => a.ord - b.ord).map((f) => ({
|
|
@@ -369,11 +478,8 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
369
478
|
input: f.input
|
|
370
479
|
} : {}
|
|
371
480
|
}));
|
|
372
|
-
const ctx = {};
|
|
373
|
-
for (const c of context) ctx[c.neuron_name] = c.value;
|
|
374
481
|
return {
|
|
375
|
-
tasks
|
|
376
|
-
context: ctx
|
|
482
|
+
tasks
|
|
377
483
|
};
|
|
378
484
|
}
|
|
379
485
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/CNSPostgresStimulationRepository.ts","../src/migrate.ts"],"sourcesContent":["export {\n CNSPostgresStimulationRepository,\n type TCNSPostgresStimulationRepositoryOptions,\n} from './CNSPostgresStimulationRepository';\nexport {\n ensureSchema,\n persistPostgresSchemaSql,\n assertSafePrefix,\n DEFAULT_PREFIX,\n type IPgQueryable,\n} from './migrate';\n","/**\n * Postgres relational store for the CNStra stimulation/attempt/task durable model\n * (`ICNSStimulationRepository`). The stimulation's `progress` is NORMALISED: the\n * outstanding frontier into `<prefix>stimulation_frontier` (one row per task) and\n * per-neuron context into `<prefix>stimulation_context` — rebuilt into `TCNSProgress`\n * on read.\n *\n * Reuses the `IPgQueryable` seam so `pg` is an optional peer: inject any pg\n * client/pool, or pass a `connectionString` for the repo to own a Pool. Behaviourally\n * matches `CNSInMemoryStimulationRepository` (listStimulations newest-first, getAttempts\n * by attemptNumber, getTasks by index, delete cascades).\n */\nimport { Pool } from 'pg';\nimport type { ICNSStimulationRepository } from '@cnstra/persist';\nimport type {\n TCNSStimulationPersisted,\n TCNSStimulationAttemptPersisted,\n TCNSStimulationTaskPersisted,\n TCNSProgress,\n TCNSSerializedTask,\n TCNSSignalRef,\n TCNSStimulationStatus,\n TCNSStimulationTaskStatus,\n} from '@cnstra/persist-dto';\nimport {\n DEFAULT_PREFIX,\n assertSafePrefix,\n ensureSchema,\n type IPgQueryable,\n} from './migrate';\n\nexport interface TCNSPostgresStimulationRepositoryOptions {\n connectionString?: string;\n /** Inject an existing pg Pool (transactions supported; not closed by `close()`). */\n pool?: Pool;\n /** Inject any queryable (reads + best-effort non-transactional writes). */\n queryable?: IPgQueryable;\n /** Table prefix (default `cns_`). */\n tablePrefix?: string;\n /** Lazily ensure the schema on first use (default true). */\n autoMigrate?: boolean;\n}\n\nconst j = (v: unknown): string => JSON.stringify(v ?? null);\nconst num = (v: unknown): number => Number(v);\nconst numOrNull = (v: unknown): number | null => (v == null ? null : Number(v));\n\nexport class CNSPostgresStimulationRepository\n implements ICNSStimulationRepository\n{\n private readonly db: IPgQueryable;\n private readonly pool?: Pool;\n private readonly ownsPool: boolean;\n private readonly p: string;\n private readonly autoMigrate: boolean;\n private ready: Promise<void> | undefined;\n\n constructor(opts: TCNSPostgresStimulationRepositoryOptions) {\n if (opts.pool) {\n this.pool = opts.pool;\n this.db = opts.pool;\n this.ownsPool = false;\n } else if (opts.connectionString) {\n this.pool = new Pool({ connectionString: opts.connectionString });\n this.db = this.pool;\n this.ownsPool = true;\n } else if (opts.queryable) {\n this.db = opts.queryable;\n this.ownsPool = false;\n } else {\n throw new Error(\n '[@cnstra/persist-postgres] needs `connectionString`, `pool`, or `queryable`'\n );\n }\n this.p = assertSafePrefix(opts.tablePrefix ?? DEFAULT_PREFIX);\n this.autoMigrate = opts.autoMigrate ?? true;\n }\n\n private ensure(): Promise<void> {\n if (!this.autoMigrate) return Promise.resolve();\n return (this.ready ??= ensureSchema(this.db, this.p));\n }\n\n /** Run `fn` in a transaction when a Pool is available; else best-effort inline. */\n private async tx<T>(fn: (db: IPgQueryable) => Promise<T>): Promise<T> {\n if (!this.pool) return fn(this.db);\n const client = await this.pool.connect();\n try {\n await client.query('BEGIN');\n const out = await fn(client as unknown as IPgQueryable);\n await client.query('COMMIT');\n return out;\n } catch (e) {\n await client.query('ROLLBACK');\n throw e;\n } finally {\n client.release();\n }\n }\n\n // ── write ──\n\n async saveStimulation(s: TCNSStimulationPersisted): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n await db.query(\n `INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, updated_at)\n VALUES ($1, $2::jsonb, $3, $4, now())\n ON CONFLICT (stimulation_id)\n DO UPDATE SET entry = EXCLUDED.entry, status = EXCLUDED.status,\n scope_name = EXCLUDED.scope_name, updated_at = now()`,\n [s.stimulationId, j(s.entry), s.status, s.scopeName ?? null]\n );\n // Replace the normalised frontier.\n await db.query(\n `DELETE FROM ${p}stimulation_frontier WHERE stimulation_id = $1`,\n [s.stimulationId]\n );\n for (let i = 0; i < s.progress.tasks.length; i++) {\n const t = s.progress.tasks[i];\n await db.query(\n `INSERT INTO ${p}stimulation_frontier\n (stimulation_id, ord, neuron_name, dendrite_collateral_name, input)\n VALUES ($1, $2, $3, $4, $5::jsonb)`,\n [\n s.stimulationId,\n i,\n t.neuronName,\n t.dendriteCollateralName,\n t.input ? j(t.input) : null,\n ]\n );\n }\n // Replace the normalised context.\n await db.query(\n `DELETE FROM ${p}stimulation_context WHERE stimulation_id = $1`,\n [s.stimulationId]\n );\n for (const [neuronName, value] of Object.entries(\n s.progress.context\n )) {\n await db.query(\n `INSERT INTO ${p}stimulation_context (stimulation_id, neuron_name, value)\n VALUES ($1, $2, $3::jsonb)`,\n [s.stimulationId, neuronName, j(value)]\n );\n }\n });\n }\n\n async saveAttempt(a: TCNSStimulationAttemptPersisted): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}stimulation_attempt\n (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,\n completed_at, hop_count, has_error, replay_of, entry)\n VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)\n ON CONFLICT (stimulation_attempt_id) DO UPDATE SET\n status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,\n hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,\n replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`,\n [\n a.stimulationAttemptId,\n a.stimulationId,\n a.attemptNumber,\n a.status,\n a.startedAt,\n a.completedAt,\n a.hopCount,\n a.hasError,\n a.replayOf,\n j(a.entry),\n ]\n );\n }\n\n async appendTask(t: TCNSStimulationTaskPersisted): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}task\n (stimulation_attempt_id, \"index\", neuron_name, dendrite_collateral_name,\n input_index, output, status, error, started_at, duration)\n VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)\n ON CONFLICT (stimulation_attempt_id, \"index\") DO NOTHING`,\n [\n t.stimulationAttemptId,\n t.index,\n t.neuronName,\n t.dendriteCollateralName,\n t.inputIndex,\n t.output ? j(t.output) : null,\n t.status,\n t.error,\n t.startedAt,\n t.duration,\n ]\n );\n }\n\n // ── read ──\n\n async getStimulation(\n stimulationId: string\n ): Promise<TCNSStimulationPersisted | undefined> {\n await this.ensure();\n const { p } = this;\n const stimRes = await this.db.query(\n `SELECT entry, status, scope_name FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n const row = stimRes.rows[0] as\n | {\n entry: TCNSSignalRef;\n status: TCNSStimulationStatus;\n scope_name: string | null;\n }\n | undefined;\n if (!row) return undefined;\n const [frontier, context] = await Promise.all([\n this.db.query(\n `SELECT ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`,\n [stimulationId]\n ),\n this.db.query(\n `SELECT neuron_name, value FROM ${p}stimulation_context WHERE stimulation_id = $1`,\n [stimulationId]\n ),\n ]);\n return {\n stimulationId,\n entry: row.entry,\n status: row.status,\n progress: this.buildProgress(\n frontier.rows as TFrontierRow[],\n context.rows as TContextRow[]\n ),\n ...(row.scope_name != null ? { scopeName: row.scope_name } : {}),\n };\n }\n\n async listStimulations(filter?: {\n scopeName?: string;\n }): Promise<TCNSStimulationPersisted[]> {\n await this.ensure();\n const { p } = this;\n const scope = filter?.scopeName ?? null;\n const stimsRes = await this.db.query(\n `SELECT stimulation_id, entry, status, scope_name FROM ${p}stimulation\n WHERE ($1::text IS NULL OR scope_name = $1)\n ORDER BY updated_at DESC`,\n [scope]\n );\n const stims = stimsRes.rows as TStimulationRow[];\n if (stims.length === 0) return [];\n const ids = stims.map(r => r.stimulation_id);\n const [frontierRes, contextRes] = await Promise.all([\n this.db.query(\n `SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`,\n [ids]\n ),\n this.db.query(\n `SELECT stimulation_id, neuron_name, value\n FROM ${p}stimulation_context WHERE stimulation_id = ANY($1)`,\n [ids]\n ),\n ]);\n const frontierBy = groupBy(\n frontierRes.rows as TFrontierRow[],\n r => r.stimulation_id\n );\n const contextBy = groupBy(\n contextRes.rows as TContextRow[],\n r => r.stimulation_id\n );\n return stims.map(r => ({\n stimulationId: r.stimulation_id,\n entry: r.entry,\n status: r.status,\n progress: this.buildProgress(\n frontierBy.get(r.stimulation_id) ?? [],\n contextBy.get(r.stimulation_id) ?? []\n ),\n ...(r.scope_name != null ? { scopeName: r.scope_name } : {}),\n }));\n }\n\n async getAttempts(\n stimulationId: string\n ): Promise<TCNSStimulationAttemptPersisted[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}stimulation_attempt WHERE stimulation_id = $1 ORDER BY attempt_number`,\n [stimulationId]\n );\n return (res.rows as TAttemptRow[]).map(rowToAttempt);\n }\n\n async getTasks(\n stimulationAttemptId: string\n ): Promise<TCNSStimulationTaskPersisted[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}task WHERE stimulation_attempt_id = $1 ORDER BY \"index\"`,\n [stimulationAttemptId]\n );\n return (res.rows as TTaskRow[]).map(rowToTask);\n }\n\n // ── lifecycle ──\n\n async delete(stimulationId: string): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n // tasks + attempts have no FK (see migrate.ts) → delete them explicitly,\n // tasks first (they reference attempt ids), then the attempts.\n await db.query(\n `DELETE FROM ${p}task WHERE stimulation_attempt_id IN\n (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt WHERE stimulation_id = $1)`,\n [stimulationId]\n );\n await db.query(\n `DELETE FROM ${p}stimulation_attempt WHERE stimulation_id = $1`,\n [stimulationId]\n );\n // FK ON DELETE CASCADE removes frontier/context.\n await db.query(\n `DELETE FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n });\n }\n\n async close(): Promise<void> {\n if (this.ownsPool && this.pool) await this.pool.end();\n }\n\n // ── helpers ──\n\n private buildProgress(\n frontier: TFrontierRow[],\n context: TContextRow[]\n ): TCNSProgress {\n const tasks: TCNSSerializedTask[] = [...frontier]\n .sort((a, b) => a.ord - b.ord)\n .map(f => ({\n neuronName: f.neuron_name,\n dendriteCollateralName: f.dendrite_collateral_name,\n ...(f.input != null ? { input: f.input } : {}),\n }));\n const ctx: Record<string, unknown> = {};\n for (const c of context) ctx[c.neuron_name] = c.value;\n return { tasks, context: ctx };\n }\n}\n\n// ── row shapes (jsonb columns come back already parsed by node-pg) ──\ntype TStimulationRow = {\n stimulation_id: string;\n entry: TCNSSignalRef;\n status: TCNSStimulationStatus;\n scope_name: string | null;\n};\ntype TFrontierRow = {\n stimulation_id: string;\n ord: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input: { collateralName: string; payload?: unknown } | null;\n};\ntype TContextRow = {\n stimulation_id: string;\n neuron_name: string;\n value: unknown;\n};\ntype TAttemptRow = {\n stimulation_attempt_id: string;\n stimulation_id: string;\n attempt_number: number;\n status: TCNSStimulationStatus;\n started_at: string | number;\n completed_at: string | number | null;\n hop_count: number;\n has_error: boolean;\n replay_of: string | null;\n entry: TCNSSignalRef[];\n};\ntype TTaskRow = {\n stimulation_attempt_id: string;\n index: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input_index: number;\n output: TCNSSignalRef | null;\n status: TCNSStimulationTaskStatus;\n error: string | null;\n started_at: string | number;\n duration: number | null;\n};\n\nfunction rowToAttempt(a: TAttemptRow): TCNSStimulationAttemptPersisted {\n return {\n stimulationAttemptId: a.stimulation_attempt_id,\n stimulationId: a.stimulation_id,\n attemptNumber: num(a.attempt_number),\n status: a.status,\n startedAt: num(a.started_at),\n completedAt: numOrNull(a.completed_at),\n hopCount: num(a.hop_count),\n hasError: Boolean(a.has_error),\n replayOf: a.replay_of ?? null,\n entry: a.entry,\n };\n}\n\nfunction rowToTask(t: TTaskRow): TCNSStimulationTaskPersisted {\n return {\n stimulationAttemptId: t.stimulation_attempt_id,\n index: num(t.index),\n neuronName: t.neuron_name,\n dendriteCollateralName: t.dendrite_collateral_name,\n inputIndex: num(t.input_index),\n output: t.output ?? null,\n status: t.status,\n error: t.error ?? null,\n startedAt: num(t.started_at),\n duration: numOrNull(t.duration),\n };\n}\n\nfunction groupBy<T, K>(rows: T[], key: (r: T) => K): Map<K, T[]> {\n const m = new Map<K, T[]>();\n for (const r of rows) {\n const k = key(r);\n const b = m.get(k);\n if (b) b.push(r);\n else m.set(k, [r]);\n }\n return m;\n}\n","/**\n * Schema / migrations for the CNStra stimulation/attempt/task relational store.\n *\n * The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`\n * or `ensureSchema(db)`, or let the repository auto-create it on first use. Five tables\n * under a configurable prefix (default `cns_`); the stimulation's `progress` is\n * NORMALISED into `stimulation_frontier` (one row per outstanding task) +\n * `stimulation_context` (one row per neuron).\n */\n\n/** Minimal pg client surface (a `pg` Pool or Client satisfies it). */\nexport interface IPgQueryable {\n query(text: string, params?: unknown[]): Promise<{ rows: unknown[] }>;\n}\n\nexport const DEFAULT_PREFIX = 'cns_';\n\n/** Guard the table prefix (it is interpolated into DDL/DML, not parameterised). */\nexport function assertSafePrefix(prefix: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {\n throw new Error(\n `[@cnstra/persist-postgres] unsafe table prefix: ${JSON.stringify(prefix)}`\n );\n }\n return prefix;\n}\n\n/** The full CREATE TABLE + CREATE INDEX DDL (idempotent). */\nexport function persistPostgresSchemaSql(prefix: string = DEFAULT_PREFIX): string {\n const p = assertSafePrefix(prefix);\n return `\nCREATE TABLE IF NOT EXISTS ${p}stimulation (\n stimulation_id text PRIMARY KEY,\n entry jsonb NOT NULL,\n status text NOT NULL,\n scope_name text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_updated_idx ON ${p}stimulation (updated_at DESC);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_scope_idx ON ${p}stimulation (scope_name);\n\nCREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (\n stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,\n ord int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input jsonb,\n PRIMARY KEY (stimulation_id, ord)\n);\n\nCREATE TABLE IF NOT EXISTS ${p}stimulation_context (\n stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,\n neuron_name text NOT NULL,\n value jsonb NOT NULL,\n PRIMARY KEY (stimulation_id, neuron_name)\n);\n\n-- NOTE: no FK on stimulation_id — over the devtools wire the persistor's\n-- saveStimulation and saveAttempt ride SEPARATE async batches processed\n-- concurrently by the server, so an attempt insert can reach Postgres before its\n-- stimulation row's transaction commits; a hard FK would reject it (23503). Same\n-- exposure as ${p}task; delete() removes attempts explicitly instead of cascading.\nCREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (\n stimulation_attempt_id text PRIMARY KEY,\n stimulation_id text NOT NULL,\n attempt_number int NOT NULL,\n status text NOT NULL,\n started_at bigint NOT NULL,\n completed_at bigint,\n hop_count int NOT NULL,\n has_error boolean NOT NULL,\n replay_of text,\n entry jsonb NOT NULL,\n UNIQUE (stimulation_id, attempt_number)\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);\n\n-- NOTE: no FK on stimulation_attempt_id — the persistor streams settled tasks\n-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),\n-- so a hard FK would reject the insert. delete() removes tasks explicitly instead.\nCREATE TABLE IF NOT EXISTS ${p}task (\n stimulation_attempt_id text NOT NULL,\n \"index\" int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input_index int NOT NULL,\n output jsonb,\n status text NOT NULL,\n error text,\n started_at bigint NOT NULL,\n duration int,\n PRIMARY KEY (stimulation_attempt_id, \"index\")\n);\n`;\n}\n\n/** Idempotently create all tables + indexes. Safe to run repeatedly. */\nexport async function ensureSchema(\n db: IPgQueryable,\n prefix: string = DEFAULT_PREFIX\n): Promise<void> {\n await db.query(persistPostgresSchemaSql(prefix));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;ACYA,gBAAqB;;;ACGd,IAAMA,iBAAiB;AAGvB,SAASC,iBAAiBC,QAAc;AAC3C,MAAI,CAAC,2BAA2BC,KAAKD,MAAAA,GAAS;AAC1C,UAAM,IAAIE,MACN,mDAAmDC,KAAKC,UAAUJ,MAAAA,CAAAA,EAAS;EAEnF;AACA,SAAOA;AACX;AAPgBD;AAUT,SAASM,yBAAyBL,SAAiBF,gBAAc;AACpE,QAAMQ,IAAIP,iBAAiBC,MAAAA;AAC3B,SAAO;6BACkBM,CAAAA;;;;;;;;6BAQAA,CAAAA,8BAA+BA,CAAAA;6BAC/BA,CAAAA,4BAA6BA,CAAAA;;6BAE7BA,CAAAA;8CACiBA,CAAAA;;;;;;;;6BAQjBA,CAAAA;8CACiBA,CAAAA;;;;;;;;;;iBAU7BA,CAAAA;6BACYA,CAAAA;;;;;;;;;;;;;6BAaAA,CAAAA,8BAA+BA,CAAAA;;;;;6BAK/BA,CAAAA;;;;;;;;;;;;;;AAc7B;AAnEgBD;AAsEhB,eAAsBE,aAClBC,IACAR,SAAiBF,gBAAc;AAE/B,QAAMU,GAAGC,MAAMJ,yBAAyBL,MAAAA,CAAAA;AAC5C;AALsBO;;;ADvDtB,IAAMG,IAAI,wBAACC,MAAuBC,KAAKC,UAAUF,gBAAK,IAAA,GAA5C;AACV,IAAMG,MAAM,wBAACH,MAAuBI,OAAOJ,CAAAA,GAA/B;AACZ,IAAMK,YAAY,wBAACL,MAA+BA,KAAK,OAAO,OAAOI,OAAOJ,CAAAA,GAA1D;AAEX,IAAMM,oCAAN,MAAMA,kCAAAA;EAUT,YAAYC,MAAgD;AAP3CC;AACAC;AACAC;AACAC;AACAC;AACTC;AAvDZ;AA0DQ,QAAIN,KAAKE,MAAM;AACX,WAAKA,OAAOF,KAAKE;AACjB,WAAKD,KAAKD,KAAKE;AACf,WAAKC,WAAW;IACpB,WAAWH,KAAKO,kBAAkB;AAC9B,WAAKL,OAAO,IAAIM,eAAK;QAAED,kBAAkBP,KAAKO;MAAiB,CAAA;AAC/D,WAAKN,KAAK,KAAKC;AACf,WAAKC,WAAW;IACpB,WAAWH,KAAKS,WAAW;AACvB,WAAKR,KAAKD,KAAKS;AACf,WAAKN,WAAW;IACpB,OAAO;AACH,YAAM,IAAIO,MACN,6EAAA;IAER;AACA,SAAKN,IAAIO,kBAAiBX,UAAKY,gBAALZ,YAAoBa,cAAAA;AAC9C,SAAKR,eAAcL,UAAKK,gBAALL,YAAoB;EAC3C;EAEQc,SAAwB;AA9EpC;AA+EQ,QAAI,CAAC,KAAKT,YAAa,QAAOU,QAAQC,QAAO;AAC7C,YAAQ,UAAKV,UAAL,iBAAKA,QAAUW,aAAa,KAAKhB,IAAI,KAAKG,CAAC;EACvD;;EAGA,MAAcc,GAAMC,IAAkD;AAClE,QAAI,CAAC,KAAKjB,KAAM,QAAOiB,GAAG,KAAKlB,EAAE;AACjC,UAAMmB,SAAS,MAAM,KAAKlB,KAAKmB,QAAO;AACtC,QAAI;AACA,YAAMD,OAAOE,MAAM,OAAA;AACnB,YAAMC,MAAM,MAAMJ,GAAGC,MAAAA;AACrB,YAAMA,OAAOE,MAAM,QAAA;AACnB,aAAOC;IACX,SAASC,GAAG;AACR,YAAMJ,OAAOE,MAAM,UAAA;AACnB,YAAME;IACV,UAAA;AACIJ,aAAOK,QAAO;IAClB;EACJ;;EAIA,MAAMC,gBAAgBC,GAA4C;AAC9D,UAAM,KAAKb,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM,KAAKc,GAAG,OAAMjB,OAAAA;AAzG5B;AA0GY,YAAMA,GAAGqB,MACL,eAAelB,CAAAA;;;;2EAKf;QAACuB,EAAEC;QAAepC,EAAEmC,EAAEE,KAAK;QAAGF,EAAEG;SAAQH,OAAEI,cAAFJ,YAAe;OAAK;AAGhE,YAAM1B,GAAGqB,MACL,eAAelB,CAAAA,kDACf;QAACuB,EAAEC;OAAc;AAErB,eAASI,IAAI,GAAGA,IAAIL,EAAEM,SAASC,MAAMC,QAAQH,KAAK;AAC9C,cAAMI,IAAIT,EAAEM,SAASC,MAAMF,CAAAA;AAC3B,cAAM/B,GAAGqB,MACL,eAAelB,CAAAA;;0DAGf;UACIuB,EAAEC;UACFI;UACAI,EAAEC;UACFD,EAAEE;UACFF,EAAEG,QAAQ/C,EAAE4C,EAAEG,KAAK,IAAI;SAC1B;MAET;AAEA,YAAMtC,GAAGqB,MACL,eAAelB,CAAAA,iDACf;QAACuB,EAAEC;OAAc;AAErB,iBAAW,CAACS,YAAYG,KAAAA,KAAUC,OAAOC,QACrCf,EAAEM,SAASU,OAAO,GACnB;AACC,cAAM1C,GAAGqB,MACL,eAAelB,CAAAA;kDAEf;UAACuB,EAAEC;UAAeS;UAAY7C,EAAEgD,KAAAA;SAAO;MAE/C;IACJ,CAAA;EACJ;EAEA,MAAMI,YAAYC,GAAmD;AACjE,UAAM,KAAK/B,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM,KAAKH,GAAGqB,MACV,eAAelB,CAAAA;;;;;;;yEAQf;MACIyC,EAAEC;MACFD,EAAEjB;MACFiB,EAAEE;MACFF,EAAEf;MACFe,EAAEG;MACFH,EAAEI;MACFJ,EAAEK;MACFL,EAAEM;MACFN,EAAEO;MACF5D,EAAEqD,EAAEhB,KAAK;KACZ;EAET;EAEA,MAAMwB,WAAWjB,GAAgD;AAC7D,UAAM,KAAKtB,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM,KAAKH,GAAGqB,MACV,eAAelB,CAAAA;;;;wEAKf;MACIgC,EAAEU;MACFV,EAAEkB;MACFlB,EAAEC;MACFD,EAAEE;MACFF,EAAEmB;MACFnB,EAAEoB,SAAShE,EAAE4C,EAAEoB,MAAM,IAAI;MACzBpB,EAAEN;MACFM,EAAEqB;MACFrB,EAAEY;MACFZ,EAAEsB;KACL;EAET;;EAIA,MAAMC,eACF/B,eAC6C;AAC7C,UAAM,KAAKd,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAMwD,UAAU,MAAM,KAAK3D,GAAGqB,MAC1B,yCAAyClB,CAAAA,yCACzC;MAACwB;KAAc;AAEnB,UAAMiC,MAAMD,QAAQE,KAAK,CAAA;AAOzB,QAAI,CAACD,IAAK,QAAOE;AACjB,UAAM,CAACC,UAAUrB,OAAAA,IAAW,MAAM5B,QAAQkD,IAAI;MAC1C,KAAKhE,GAAGqB,MACJ;wBACQlB,CAAAA,+DACR;QAACwB;OAAc;MAEnB,KAAK3B,GAAGqB,MACJ,kCAAkClB,CAAAA,iDAClC;QAACwB;OAAc;KAEtB;AACD,WAAO;MACHA;MACAC,OAAOgC,IAAIhC;MACXC,QAAQ+B,IAAI/B;MACZG,UAAU,KAAKiC,cACXF,SAASF,MACTnB,QAAQmB,IAAI;MAEhB,GAAID,IAAIM,cAAc,OAAO;QAAEpC,WAAW8B,IAAIM;MAAW,IAAI,CAAC;IAClE;EACJ;EAEA,MAAMC,iBAAiBC,QAEiB;AAtP5C;AAuPQ,UAAM,KAAKvD,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAMkE,SAAQD,sCAAQtC,cAARsC,YAAqB;AACnC,UAAME,WAAW,MAAM,KAAKtE,GAAGqB,MAC3B,yDAAyDlB,CAAAA;;wCAGzD;MAACkE;KAAM;AAEX,UAAME,QAAQD,SAAST;AACvB,QAAIU,MAAMrC,WAAW,EAAG,QAAO,CAAA;AAC/B,UAAMsC,MAAMD,MAAME,IAAIC,CAAAA,MAAKA,EAAEC,cAAc;AAC3C,UAAM,CAACC,aAAaC,UAAAA,IAAc,MAAM/D,QAAQkD,IAAI;MAChD,KAAKhE,GAAGqB,MACJ;wBACQlB,CAAAA,oFACR;QAACqE;OAAI;MAET,KAAKxE,GAAGqB,MACJ;wBACQlB,CAAAA,sDACR;QAACqE;OAAI;KAEZ;AACD,UAAMM,aAAaC,QACfH,YAAYf,MACZa,CAAAA,MAAKA,EAAEC,cAAc;AAEzB,UAAMK,YAAYD,QACdF,WAAWhB,MACXa,CAAAA,MAAKA,EAAEC,cAAc;AAEzB,WAAOJ,MAAME,IAAIC,CAAAA,MAAAA;AAvRzB,UAAAO,KAAA;AAuR+B;QACnBtD,eAAe+C,EAAEC;QACjB/C,OAAO8C,EAAE9C;QACTC,QAAQ6C,EAAE7C;QACVG,UAAU,KAAKiC,eACXa,MAAAA,WAAWI,IAAIR,EAAEC,cAAc,MAA/BG,OAAAA,MAAoC,CAAA,IACpCE,eAAUE,IAAIR,EAAEC,cAAc,MAA9BK,YAAmC,CAAA,CAAE;QAEzC,GAAIN,EAAER,cAAc,OAAO;UAAEpC,WAAW4C,EAAER;QAAW,IAAI,CAAC;MAC9D;KAAA;EACJ;EAEA,MAAMiB,YACFxD,eAC0C;AAC1C,UAAM,KAAKd,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAMiF,MAAM,MAAM,KAAKpF,GAAGqB,MACtB,iBAAiBlB,CAAAA,yEACjB;MAACwB;KAAc;AAEnB,WAAQyD,IAAIvB,KAAuBY,IAAIY,YAAAA;EAC3C;EAEA,MAAMC,SACFzC,sBACuC;AACvC,UAAM,KAAKhC,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAMiF,MAAM,MAAM,KAAKpF,GAAGqB,MACtB,iBAAiBlB,CAAAA,2DACjB;MAAC0C;KAAqB;AAE1B,WAAQuC,IAAIvB,KAAoBY,IAAIc,SAAAA;EACxC;;EAIA,MAAMC,OAAO7D,eAAsC;AAC/C,UAAM,KAAKd,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM,KAAKc,GAAG,OAAMjB,OAAAA;AAGhB,YAAMA,GAAGqB,MACL,eAAelB,CAAAA;0DAC2BA,CAAAA,kDAC1C;QAACwB;OAAc;AAEnB,YAAM3B,GAAGqB,MACL,eAAelB,CAAAA,iDACf;QAACwB;OAAc;AAGnB,YAAM3B,GAAGqB,MACL,eAAelB,CAAAA,yCACf;QAACwB;OAAc;IAEvB,CAAA;EACJ;EAEA,MAAM8D,QAAuB;AACzB,QAAI,KAAKvF,YAAY,KAAKD,KAAM,OAAM,KAAKA,KAAKyF,IAAG;EACvD;;EAIQzB,cACJF,UACArB,SACY;AACZ,UAAMT,QAA8B;SAAI8B;MACnC4B,KAAK,CAAC/C,GAAGgD,MAAMhD,EAAEiD,MAAMD,EAAEC,GAAG,EAC5BpB,IAAIqB,CAAAA,OAAM;MACP1D,YAAY0D,EAAEC;MACd1D,wBAAwByD,EAAEE;MAC1B,GAAIF,EAAExD,SAAS,OAAO;QAAEA,OAAOwD,EAAExD;MAAM,IAAI,CAAC;IAChD,EAAA;AACJ,UAAM2D,MAA+B,CAAC;AACtC,eAAWC,KAAKxD,QAASuD,KAAIC,EAAEH,WAAW,IAAIG,EAAE3D;AAChD,WAAO;MAAEN;MAAOS,SAASuD;IAAI;EACjC;AACJ;AA1TanG;AAAN,IAAMA,mCAAN;AAwWP,SAASuF,aAAazC,GAAc;AAvZpC;AAwZI,SAAO;IACHC,sBAAsBD,EAAEuD;IACxBxE,eAAeiB,EAAE+B;IACjB7B,eAAenD,IAAIiD,EAAEwD,cAAc;IACnCvE,QAAQe,EAAEf;IACVkB,WAAWpD,IAAIiD,EAAEyD,UAAU;IAC3BrD,aAAanD,UAAU+C,EAAE0D,YAAY;IACrCrD,UAAUtD,IAAIiD,EAAE2D,SAAS;IACzBrD,UAAUsD,QAAQ5D,EAAE6D,SAAS;IAC7BtD,WAAUP,OAAE8D,cAAF9D,YAAe;IACzBhB,OAAOgB,EAAEhB;EACb;AACJ;AAbSyD;AAeT,SAASE,UAAUpD,GAAW;AAta9B;AAuaI,SAAO;IACHU,sBAAsBV,EAAEgE;IACxB9C,OAAO1D,IAAIwC,EAAEkB,KAAK;IAClBjB,YAAYD,EAAE4D;IACd1D,wBAAwBF,EAAE6D;IAC1B1C,YAAY3D,IAAIwC,EAAEwE,WAAW;IAC7BpD,SAAQpB,OAAEoB,WAAFpB,YAAY;IACpBN,QAAQM,EAAEN;IACV2B,QAAOrB,OAAEqB,UAAFrB,YAAW;IAClBY,WAAWpD,IAAIwC,EAAEkE,UAAU;IAC3B5C,UAAU5D,UAAUsC,EAAEsB,QAAQ;EAClC;AACJ;AAbS8B;AAeT,SAASR,QAAclB,MAAW+C,KAAgB;AAC9C,QAAMC,IAAI,oBAAIC,IAAAA;AACd,aAAWpC,KAAKb,MAAM;AAClB,UAAMkD,IAAIH,IAAIlC,CAAAA;AACd,UAAMkB,IAAIiB,EAAE3B,IAAI6B,CAAAA;AAChB,QAAInB,EAAGA,GAAEoB,KAAKtC,CAAAA;QACTmC,GAAEI,IAAIF,GAAG;MAACrC;KAAE;EACrB;AACA,SAAOmC;AACX;AATS9B;","names":["DEFAULT_PREFIX","assertSafePrefix","prefix","test","Error","JSON","stringify","persistPostgresSchemaSql","p","ensureSchema","db","query","j","v","JSON","stringify","num","Number","numOrNull","CNSPostgresStimulationRepository","opts","db","pool","ownsPool","p","autoMigrate","ready","connectionString","Pool","queryable","Error","assertSafePrefix","tablePrefix","DEFAULT_PREFIX","ensure","Promise","resolve","ensureSchema","tx","fn","client","connect","query","out","e","release","saveStimulation","s","stimulationId","entry","status","scopeName","i","progress","tasks","length","t","neuronName","dendriteCollateralName","input","value","Object","entries","context","saveAttempt","a","stimulationAttemptId","attemptNumber","startedAt","completedAt","hopCount","hasError","replayOf","appendTask","index","inputIndex","output","error","duration","getStimulation","stimRes","row","rows","undefined","frontier","all","buildProgress","scope_name","listStimulations","filter","scope","stimsRes","stims","ids","map","r","stimulation_id","frontierRes","contextRes","frontierBy","groupBy","contextBy","_a","get","getAttempts","res","rowToAttempt","getTasks","rowToTask","delete","close","end","sort","b","ord","f","neuron_name","dendrite_collateral_name","ctx","c","stimulation_attempt_id","attempt_number","started_at","completed_at","hop_count","Boolean","has_error","replay_of","input_index","key","m","Map","k","push","set"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/CNSPostgresStimulationRepository.ts","../src/migrate.ts"],"sourcesContent":["export {\n CNSPostgresStimulationRepository,\n type TCNSPostgresStimulationRepositoryOptions,\n} from './CNSPostgresStimulationRepository';\nexport {\n ensureSchema,\n persistPostgresSchemaSql,\n assertSafePrefix,\n DEFAULT_PREFIX,\n type IPgQueryable,\n} from './migrate';\n","/**\n * Postgres relational store for the CNStra stimulation/attempt/task resumable model\n * (`ICNSStimulationRepository`). The stimulation's `progress` is NORMALISED: the\n * outstanding frontier into `<prefix>stimulation_frontier` (one row per task) —\n * rebuilt into `TCNSProgress` on read.\n *\n * Reuses the `IPgQueryable` seam so `pg` is an optional peer: inject any pg\n * client/pool, or pass a `connectionString` for the repo to own a Pool. Behaviourally\n * matches `CNSInMemoryStimulationRepository` (listStimulations newest-first, getAttempts\n * by attemptNumber, getTasks by index, delete cascades).\n */\nimport { Pool } from 'pg';\nimport type {\n ICNSStimulationRepository,\n ICNSStimulationRosterQuery,\n TCNSRosterEntry,\n} from '@cnstra/persist';\nimport { computeRosterFacets, encodeRosterCursor } from '@cnstra/persist';\nimport type {\n TCNSStimulationState,\n TCNSStimulationAttempt,\n TCNSStimulationTask,\n TCNSProgress,\n TCNSPlannedTaskJSON,\n TCNSSignalJSON,\n ECNSStimulationStatus,\n ECNSStimulationTaskStatus,\n TCNSStimulationRosterFilter,\n TCNSStimulationRosterRow,\n TCNSStimulationRosterPage,\n TCNSStimulationRosterFacets,\n} from '@cnstra/persist-dto';\nimport {\n DEFAULT_PREFIX,\n assertSafePrefix,\n ensureSchema,\n type IPgQueryable,\n} from './migrate';\n\nexport interface TCNSPostgresStimulationRepositoryOptions {\n connectionString?: string;\n /** Inject an existing pg Pool (transactions supported; not closed by `close()`). */\n pool?: Pool;\n /** Inject any queryable (reads + best-effort non-transactional writes). */\n queryable?: IPgQueryable;\n /** Table prefix (default `cns_`). */\n tablePrefix?: string;\n /** Lazily ensure the schema on first use (default true). */\n autoMigrate?: boolean;\n}\n\nconst j = (v: unknown): string => JSON.stringify(v ?? null);\nconst num = (v: unknown): number => Number(v);\nconst numOrNull = (v: unknown): number | null => (v == null ? null : Number(v));\n\nexport class CNSPostgresStimulationRepository\n implements ICNSStimulationRepository, ICNSStimulationRosterQuery\n{\n private readonly db: IPgQueryable;\n private readonly pool?: Pool;\n private readonly ownsPool: boolean;\n private readonly p: string;\n private readonly autoMigrate: boolean;\n private ready: Promise<void> | undefined;\n\n constructor(opts: TCNSPostgresStimulationRepositoryOptions) {\n if (opts.pool) {\n this.pool = opts.pool;\n this.db = opts.pool;\n this.ownsPool = false;\n } else if (opts.connectionString) {\n this.pool = new Pool({ connectionString: opts.connectionString });\n this.db = this.pool;\n this.ownsPool = true;\n } else if (opts.queryable) {\n this.db = opts.queryable;\n this.ownsPool = false;\n } else {\n throw new Error(\n '[@cnstra/persist-postgres] needs `connectionString`, `pool`, or `queryable`'\n );\n }\n this.p = assertSafePrefix(opts.tablePrefix ?? DEFAULT_PREFIX);\n this.autoMigrate = opts.autoMigrate ?? true;\n }\n\n private ensure(): Promise<void> {\n if (!this.autoMigrate) return Promise.resolve();\n return (this.ready ??= ensureSchema(this.db, this.p));\n }\n\n /** Run `fn` in a transaction when a Pool is available; else best-effort inline. */\n private async tx<T>(fn: (db: IPgQueryable) => Promise<T>): Promise<T> {\n if (!this.pool) return fn(this.db);\n const client = await this.pool.connect();\n try {\n await client.query('BEGIN');\n const out = await fn(client as unknown as IPgQueryable);\n await client.query('COMMIT');\n return out;\n } catch (e) {\n await client.query('ROLLBACK');\n throw e;\n } finally {\n client.release();\n }\n }\n\n // ── write ──\n\n async saveStimulation(s: TCNSStimulationState): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n await db.query(\n `INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, updated_at)\n VALUES ($1, $2::jsonb, $3, $4, now())\n ON CONFLICT (stimulation_id)\n DO UPDATE SET entry = EXCLUDED.entry, status = EXCLUDED.status,\n scope_name = EXCLUDED.scope_name, updated_at = now()`,\n [s.stimulationId, j(s.entry), s.status, s.scopeName ?? null]\n );\n // Replace the normalised frontier.\n await db.query(\n `DELETE FROM ${p}stimulation_frontier WHERE stimulation_id = $1`,\n [s.stimulationId]\n );\n for (let i = 0; i < s.progress.tasks.length; i++) {\n const t = s.progress.tasks[i];\n await db.query(\n `INSERT INTO ${p}stimulation_frontier\n (stimulation_id, ord, neuron_name, dendrite_collateral_name, input)\n VALUES ($1, $2, $3, $4, $5::jsonb)`,\n [\n s.stimulationId,\n i,\n t.neuronName,\n t.dendriteCollateralName,\n t.input ? j(t.input) : null,\n ]\n );\n }\n });\n }\n\n async saveAttempt(a: TCNSStimulationAttempt): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}stimulation_attempt\n (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,\n completed_at, hop_count, has_error, replay_of, entry)\n VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)\n ON CONFLICT (stimulation_attempt_id) DO UPDATE SET\n status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,\n hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,\n replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`,\n [\n a.stimulationAttemptId,\n a.stimulationId,\n a.attemptNumber,\n a.status,\n a.startedAt,\n a.completedAt,\n a.hopCount,\n a.hasError,\n a.replayOf,\n j(a.entry),\n ]\n );\n }\n\n async appendTask(t: TCNSStimulationTask): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}task\n (stimulation_attempt_id, \"index\", neuron_name, dendrite_collateral_name,\n input_index, output, status, error, started_at, duration)\n VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)\n ON CONFLICT (stimulation_attempt_id, \"index\") DO NOTHING`,\n [\n t.stimulationAttemptId,\n t.index,\n t.neuronName,\n t.dendriteCollateralName,\n t.inputIndex,\n t.output ? j(t.output) : null,\n t.status,\n t.error,\n t.startedAt,\n t.duration,\n ]\n );\n }\n\n // ── read ──\n\n async getStimulation(\n stimulationId: string\n ): Promise<TCNSStimulationState | undefined> {\n await this.ensure();\n const { p } = this;\n const stimRes = await this.db.query(\n `SELECT entry, status, scope_name FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n const row = stimRes.rows[0] as\n | {\n entry: TCNSSignalJSON;\n status: ECNSStimulationStatus;\n scope_name: string | null;\n }\n | undefined;\n if (!row) return undefined;\n const frontier = await this.db.query(\n `SELECT ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`,\n [stimulationId]\n );\n return {\n stimulationId,\n entry: row.entry,\n status: row.status,\n progress: this.buildProgress(frontier.rows as TFrontierRow[]),\n ...(row.scope_name != null ? { scopeName: row.scope_name } : {}),\n };\n }\n\n async listStimulations(filter?: {\n scopeName?: string;\n }): Promise<TCNSStimulationState[]> {\n await this.ensure();\n const { p } = this;\n const scope = filter?.scopeName ?? null;\n const stimsRes = await this.db.query(\n `SELECT stimulation_id, entry, status, scope_name FROM ${p}stimulation\n WHERE ($1::text IS NULL OR scope_name = $1)\n ORDER BY updated_at DESC`,\n [scope]\n );\n const stims = stimsRes.rows as TStimulationRow[];\n if (stims.length === 0) return [];\n const ids = stims.map(r => r.stimulation_id);\n const frontierRes = await this.db.query(\n `SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`,\n [ids]\n );\n const frontierBy = groupBy(\n frontierRes.rows as TFrontierRow[],\n r => r.stimulation_id\n );\n return stims.map(r => ({\n stimulationId: r.stimulation_id,\n entry: r.entry,\n status: r.status,\n progress: this.buildProgress(\n frontierBy.get(r.stimulation_id) ?? []\n ),\n ...(r.scope_name != null ? { scopeName: r.scope_name } : {}),\n }));\n }\n\n async getAttempts(\n stimulationId: string\n ): Promise<TCNSStimulationAttempt[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}stimulation_attempt WHERE stimulation_id = $1 ORDER BY attempt_number`,\n [stimulationId]\n );\n return (res.rows as TAttemptRow[]).map(rowToAttempt);\n }\n\n async getTasks(\n stimulationAttemptId: string\n ): Promise<TCNSStimulationTask[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}task WHERE stimulation_attempt_id = $1 ORDER BY \"index\"`,\n [stimulationAttemptId]\n );\n return (res.rows as TTaskRow[]).map(rowToTask);\n }\n\n // ── roster query capability (filter + keyset + facets pushed into SQL) ──\n\n /**\n * Derive every filtered roster row in one CTE: `startedAt` = MIN(attempt),\n * `completedAt` = the latest attempt's, `hopCount` = SUM, `hasError` from\n * status, and the DISTINCT activated neuron names (for the neurons lens +\n * filter). Newest-first (`startedAt DESC, stimulationId DESC`) so the keyset\n * cursor is stable. `limit` is applied only when paginating (roster); facets\n * pull the whole filtered set.\n */\n private async selectRoster(\n filter: TCNSStimulationRosterFilter,\n opts?: { cursor?: string; limit?: number }\n ): Promise<{ entries: TCNSRosterEntry[]; total: number }> {\n await this.ensure();\n const { p } = this;\n // Decode the keyset cursor → the (startedAt, stimulationId) to page past.\n let curStarted: number | null = null;\n let curId: string | null = null;\n if (opts?.cursor) {\n const [ts, id] = Buffer.from(opts.cursor, 'base64')\n .toString('utf8')\n .split('|');\n curStarted = Number(ts);\n curId = id ?? '';\n }\n const params: unknown[] = [\n filter.scopeName ?? null,\n filter.status ?? null,\n filter.entryCollateralName ?? null,\n filter.neuronName ?? null,\n filter.text ? filter.text.toLowerCase() : null,\n filter.since ?? null,\n filter.until ?? null,\n curStarted,\n curId,\n ];\n const limitClause =\n opts?.limit != null ? ` LIMIT ${Math.max(0, opts.limit | 0)}` : '';\n const sql = `\n WITH agg AS (\n SELECT a.stimulation_id,\n MIN(a.started_at) AS started_at,\n SUM(a.hop_count) AS hop_count,\n (ARRAY_AGG(a.completed_at ORDER BY a.attempt_number DESC))[1] AS last_completed_at\n FROM ${p}stimulation_attempt a\n GROUP BY a.stimulation_id\n ),\n neu AS (\n SELECT ta.stimulation_id,\n ARRAY_AGG(DISTINCT t.neuron_name) AS neurons\n FROM ${p}task t\n JOIN ${p}stimulation_attempt ta\n ON ta.stimulation_attempt_id = t.stimulation_attempt_id\n GROUP BY ta.stimulation_id\n ),\n roster AS (\n SELECT s.stimulation_id, s.entry, s.status, s.scope_name,\n COALESCE(agg.started_at, 0) AS started_at,\n CASE WHEN s.status = 'running' THEN NULL\n ELSE agg.last_completed_at END AS completed_at,\n COALESCE(agg.hop_count, 0) AS hop_count,\n (s.status = 'failed') AS has_error,\n COALESCE(neu.neurons, ARRAY[]::text[]) AS neurons\n FROM ${p}stimulation s\n LEFT JOIN agg ON agg.stimulation_id = s.stimulation_id\n LEFT JOIN neu ON neu.stimulation_id = s.stimulation_id\n )\n SELECT *, COUNT(*) OVER() AS total_count FROM roster\n WHERE ($1::text IS NULL OR scope_name = $1)\n AND ($2::text IS NULL OR status = $2)\n AND ($3::text IS NULL OR entry->>'collateralName' = $3)\n AND ($4::text IS NULL OR $4 = ANY(neurons))\n AND ($5::text IS NULL OR lower(stimulation_id) LIKE '%'||$5||'%')\n AND ($6::bigint IS NULL OR started_at >= $6)\n AND ($7::bigint IS NULL OR started_at <= $7)\n AND ($8::bigint IS NULL OR (started_at, stimulation_id) < ($8, $9))\n ORDER BY started_at DESC, stimulation_id DESC${limitClause}`;\n const res = await this.db.query(sql, params);\n const rows = res.rows as TRosterRow[];\n const total = rows.length ? Number(rows[0].total_count) : 0;\n const entries: TCNSRosterEntry[] = rows.map(r => ({\n row: {\n stimulationId: r.stimulation_id,\n scopeName: r.scope_name ?? undefined,\n entry: r.entry as TCNSSignalJSON,\n status: r.status,\n startedAt: Number(r.started_at),\n completedAt: r.completed_at == null ? null : Number(r.completed_at),\n hopCount: Number(r.hop_count),\n hasError: r.has_error,\n },\n neurons: r.neurons ?? [],\n }));\n return { entries, total };\n }\n\n async queryRoster(\n filter: TCNSStimulationRosterFilter,\n opts: { cursor?: string; limit: number }\n ): Promise<TCNSStimulationRosterPage> {\n const { entries, total } = await this.selectRoster(filter, opts);\n const rows: TCNSStimulationRosterRow[] = entries.map(e => e.row);\n const lastRow = rows[rows.length - 1];\n // A full page implies there may be more; the window `total` is authoritative.\n const hasMore = rows.length >= opts.limit && rows.length < total;\n return {\n rows,\n nextCursor: hasMore && lastRow ? encodeRosterCursor(lastRow) : null,\n total,\n };\n }\n\n async queryFacets(\n filter: TCNSStimulationRosterFilter,\n opts?: { metricPath?: string }\n ): Promise<TCNSStimulationRosterFacets> {\n // Filter is pushed into SQL; the small GROUP-BY tally + histogram reuse the\n // shared reference aggregation so counts match the in-memory store exactly.\n const { entries } = await this.selectRoster(filter);\n return computeRosterFacets(entries, opts);\n }\n\n // ── lifecycle ──\n\n async delete(stimulationId: string): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n // tasks + attempts have no FK (see migrate.ts) → delete them explicitly,\n // tasks first (they reference attempt ids), then the attempts.\n await db.query(\n `DELETE FROM ${p}task WHERE stimulation_attempt_id IN\n (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt WHERE stimulation_id = $1)`,\n [stimulationId]\n );\n await db.query(\n `DELETE FROM ${p}stimulation_attempt WHERE stimulation_id = $1`,\n [stimulationId]\n );\n // FK ON DELETE CASCADE removes the frontier.\n await db.query(\n `DELETE FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n });\n }\n\n /**\n * Retention: prune stimulations not updated for `olderThanMs` (with their\n * attempts/tasks/frontier). A run's `updated_at` stops advancing once it\n * settles, so old finished runs age out while an active one stays fresh — the same\n * semantics as the in-memory store's `ttlMs` and the pg-boss checkpoint's\n * `deleteStale`. Run it on a schedule (cron). Returns the number removed.\n */\n async deleteStale(olderThanMs: number): Promise<number> {\n await this.ensure();\n const { p } = this;\n const params = [Math.max(0, Math.floor(olderThanMs))];\n const cond = `updated_at < now() - ($1::bigint * interval '1 millisecond')`;\n const staleIds = `SELECT stimulation_id FROM ${p}stimulation WHERE ${cond}`;\n return this.tx(async db => {\n // tasks + attempts have no FK → delete explicitly (tasks first), then the\n // stimulations (FK cascade removes the frontier).\n await db.query(\n `DELETE FROM ${p}task WHERE stimulation_attempt_id IN\n (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt\n WHERE stimulation_id IN (${staleIds}))`,\n params\n );\n await db.query(\n `DELETE FROM ${p}stimulation_attempt WHERE stimulation_id IN (${staleIds})`,\n params\n );\n const res = await db.query(\n `DELETE FROM ${p}stimulation WHERE ${cond} RETURNING stimulation_id`,\n params\n );\n return res.rows.length;\n });\n }\n\n async close(): Promise<void> {\n if (this.ownsPool && this.pool) await this.pool.end();\n }\n\n // ── helpers ──\n\n private buildProgress(frontier: TFrontierRow[]): TCNSProgress {\n const tasks: TCNSPlannedTaskJSON[] = [...frontier]\n .sort((a, b) => a.ord - b.ord)\n .map(f => ({\n neuronName: f.neuron_name,\n dendriteCollateralName: f.dendrite_collateral_name,\n ...(f.input != null ? { input: f.input } : {}),\n }));\n return { tasks };\n }\n}\n\n// ── row shapes (jsonb columns come back already parsed by node-pg) ──\ntype TStimulationRow = {\n stimulation_id: string;\n entry: TCNSSignalJSON;\n status: ECNSStimulationStatus;\n scope_name: string | null;\n};\ntype TFrontierRow = {\n stimulation_id: string;\n ord: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input: TCNSSignalJSON | null;\n};\ntype TAttemptRow = {\n stimulation_attempt_id: string;\n stimulation_id: string;\n attempt_number: number;\n status: ECNSStimulationStatus;\n started_at: string | number;\n completed_at: string | number | null;\n hop_count: number;\n has_error: boolean;\n replay_of: string | null;\n entry: TCNSSignalJSON[];\n};\ntype TTaskRow = {\n stimulation_attempt_id: string;\n index: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input_index: number;\n output: TCNSSignalJSON | null;\n status: ECNSStimulationTaskStatus;\n error: string | null;\n started_at: string | number;\n duration: number | null;\n};\n/** One roster CTE row (see `selectRoster`), with the window total attached. */\ntype TRosterRow = {\n stimulation_id: string;\n entry: unknown;\n status: string;\n scope_name: string | null;\n started_at: string | number;\n completed_at: string | number | null;\n hop_count: string | number;\n has_error: boolean;\n neurons: string[] | null;\n total_count: string | number;\n};\n\nfunction rowToAttempt(a: TAttemptRow): TCNSStimulationAttempt {\n return {\n stimulationAttemptId: a.stimulation_attempt_id,\n stimulationId: a.stimulation_id,\n attemptNumber: num(a.attempt_number),\n status: a.status,\n startedAt: num(a.started_at),\n completedAt: numOrNull(a.completed_at),\n hopCount: num(a.hop_count),\n hasError: Boolean(a.has_error),\n replayOf: a.replay_of ?? null,\n entry: a.entry,\n };\n}\n\nfunction rowToTask(t: TTaskRow): TCNSStimulationTask {\n return {\n stimulationAttemptId: t.stimulation_attempt_id,\n index: num(t.index),\n neuronName: t.neuron_name,\n dendriteCollateralName: t.dendrite_collateral_name,\n inputIndex: num(t.input_index),\n output: t.output ?? null,\n status: t.status,\n error: t.error ?? null,\n startedAt: num(t.started_at),\n duration: numOrNull(t.duration),\n };\n}\n\nfunction groupBy<T, K>(rows: T[], key: (r: T) => K): Map<K, T[]> {\n const m = new Map<K, T[]>();\n for (const r of rows) {\n const k = key(r);\n const b = m.get(k);\n if (b) b.push(r);\n else m.set(k, [r]);\n }\n return m;\n}\n","/**\n * Schema / migrations for the CNStra stimulation/attempt/task relational store.\n *\n * The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`\n * or `ensureSchema(db)`, or let the repository auto-create it on first use. Tables\n * under a configurable prefix (default `cns_`); the stimulation's `progress` is\n * NORMALISED into `stimulation_frontier` (one row per outstanding task).\n */\n\n/** Minimal pg client surface (a `pg` Pool or Client satisfies it). */\nexport interface IPgQueryable {\n query(text: string, params?: unknown[]): Promise<{ rows: unknown[] }>;\n}\n\nexport const DEFAULT_PREFIX = 'cns_';\n\n/** Guard the table prefix (it is interpolated into DDL/DML, not parameterised). */\nexport function assertSafePrefix(prefix: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {\n throw new Error(\n `[@cnstra/persist-postgres] unsafe table prefix: ${JSON.stringify(prefix)}`\n );\n }\n return prefix;\n}\n\n/** The full CREATE TABLE + CREATE INDEX DDL (idempotent). */\nexport function persistPostgresSchemaSql(prefix: string = DEFAULT_PREFIX): string {\n const p = assertSafePrefix(prefix);\n return `\nCREATE TABLE IF NOT EXISTS ${p}stimulation (\n stimulation_id text PRIMARY KEY,\n entry jsonb NOT NULL,\n status text NOT NULL,\n scope_name text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_updated_idx ON ${p}stimulation (updated_at DESC);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_scope_idx ON ${p}stimulation (scope_name);\n\nCREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (\n stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,\n ord int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input jsonb,\n PRIMARY KEY (stimulation_id, ord)\n);\n\n-- NOTE: no FK on stimulation_id — over the devtools wire the persistor's\n-- saveStimulation and saveAttempt ride SEPARATE async batches processed\n-- concurrently by the server, so an attempt insert can reach Postgres before its\n-- stimulation row's transaction commits; a hard FK would reject it (23503). Same\n-- exposure as ${p}task; delete() removes attempts explicitly instead of cascading.\nCREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (\n stimulation_attempt_id text PRIMARY KEY,\n stimulation_id text NOT NULL,\n attempt_number int NOT NULL,\n status text NOT NULL,\n started_at bigint NOT NULL,\n completed_at bigint,\n hop_count int NOT NULL,\n has_error boolean NOT NULL,\n replay_of text,\n entry jsonb NOT NULL,\n UNIQUE (stimulation_id, attempt_number)\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);\n-- roster query: MIN(started_at)/keyset ordering per run.\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_started_idx ON ${p}stimulation_attempt (stimulation_id, started_at);\n\n-- NOTE: no FK on stimulation_attempt_id — the persistor streams settled tasks\n-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),\n-- so a hard FK would reject the insert. delete() removes tasks explicitly instead.\nCREATE TABLE IF NOT EXISTS ${p}task (\n stimulation_attempt_id text NOT NULL,\n \"index\" int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input_index int NOT NULL,\n output jsonb,\n status text NOT NULL,\n error text,\n started_at bigint NOT NULL,\n duration int,\n PRIMARY KEY (stimulation_attempt_id, \"index\")\n);\n-- roster neurons lens: DISTINCT neuron_name per attempt / neuronName filter.\nCREATE INDEX IF NOT EXISTS ${p}task_neuron_idx ON ${p}task (neuron_name);\n`;\n}\n\n/** Idempotently create all tables + indexes. Safe to run repeatedly. */\nexport async function ensureSchema(\n db: IPgQueryable,\n prefix: string = DEFAULT_PREFIX\n): Promise<void> {\n await db.query(persistPostgresSchemaSql(prefix));\n}\n"],"mappings":"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","names":["DEFAULT_PREFIX","assertSafePrefix","prefix","test","Error","JSON","stringify","persistPostgresSchemaSql","p","ensureSchema","db","query","j","v","JSON","stringify","num","Number","numOrNull","CNSPostgresStimulationRepository","opts","db","pool","ownsPool","p","autoMigrate","ready","connectionString","Pool","queryable","Error","assertSafePrefix","tablePrefix","DEFAULT_PREFIX","ensure","Promise","resolve","ensureSchema","tx","fn","client","connect","query","out","e","release","saveStimulation","s","stimulationId","entry","status","scopeName","i","progress","tasks","length","t","neuronName","dendriteCollateralName","input","saveAttempt","a","stimulationAttemptId","attemptNumber","startedAt","completedAt","hopCount","hasError","replayOf","appendTask","index","inputIndex","output","error","duration","getStimulation","stimRes","row","rows","undefined","frontier","buildProgress","scope_name","listStimulations","filter","scope","stimsRes","stims","ids","map","r","stimulation_id","frontierRes","frontierBy","groupBy","_a","get","getAttempts","res","rowToAttempt","getTasks","rowToTask","selectRoster","curStarted","curId","cursor","ts","id","Buffer","from","toString","split","params","entryCollateralName","text","toLowerCase","since","until","limitClause","limit","Math","max","sql","total","total_count","entries","_b","started_at","completed_at","hop_count","has_error","neurons","queryRoster","lastRow","hasMore","nextCursor","encodeRosterCursor","queryFacets","computeRosterFacets","delete","deleteStale","olderThanMs","floor","cond","staleIds","close","end","sort","b","ord","f","neuron_name","dendrite_collateral_name","stimulation_attempt_id","attempt_number","Boolean","replay_of","input_index","key","m","Map","k","push","set"]}
|
package/dist/index.mjs
CHANGED
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
assertSafePrefix,
|
|
6
6
|
ensureSchema,
|
|
7
7
|
persistPostgresSchemaSql
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-5FLIKV6I.mjs";
|
|
9
9
|
|
|
10
10
|
// src/CNSPostgresStimulationRepository.ts
|
|
11
11
|
import { Pool } from "pg";
|
|
12
|
+
import { computeRosterFacets, encodeRosterCursor } from "@cnstra/persist";
|
|
12
13
|
var j = /* @__PURE__ */ __name((v) => JSON.stringify(v != null ? v : null), "j");
|
|
13
14
|
var num = /* @__PURE__ */ __name((v) => Number(v), "num");
|
|
14
15
|
var numOrNull = /* @__PURE__ */ __name((v) => v == null ? null : Number(v), "numOrNull");
|
|
@@ -92,17 +93,6 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
92
93
|
t.input ? j(t.input) : null
|
|
93
94
|
]);
|
|
94
95
|
}
|
|
95
|
-
await db.query(`DELETE FROM ${p}stimulation_context WHERE stimulation_id = $1`, [
|
|
96
|
-
s.stimulationId
|
|
97
|
-
]);
|
|
98
|
-
for (const [neuronName, value] of Object.entries(s.progress.context)) {
|
|
99
|
-
await db.query(`INSERT INTO ${p}stimulation_context (stimulation_id, neuron_name, value)
|
|
100
|
-
VALUES ($1, $2, $3::jsonb)`, [
|
|
101
|
-
s.stimulationId,
|
|
102
|
-
neuronName,
|
|
103
|
-
j(value)
|
|
104
|
-
]);
|
|
105
|
-
}
|
|
106
96
|
});
|
|
107
97
|
}
|
|
108
98
|
async saveAttempt(a) {
|
|
@@ -157,20 +147,15 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
157
147
|
]);
|
|
158
148
|
const row = stimRes.rows[0];
|
|
159
149
|
if (!row) return void 0;
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
stimulationId
|
|
164
|
-
]),
|
|
165
|
-
this.db.query(`SELECT neuron_name, value FROM ${p}stimulation_context WHERE stimulation_id = $1`, [
|
|
166
|
-
stimulationId
|
|
167
|
-
])
|
|
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`, [
|
|
152
|
+
stimulationId
|
|
168
153
|
]);
|
|
169
154
|
return {
|
|
170
155
|
stimulationId,
|
|
171
156
|
entry: row.entry,
|
|
172
157
|
status: row.status,
|
|
173
|
-
progress: this.buildProgress(frontier.rows
|
|
158
|
+
progress: this.buildProgress(frontier.rows),
|
|
174
159
|
...row.scope_name != null ? {
|
|
175
160
|
scopeName: row.scope_name
|
|
176
161
|
} : {}
|
|
@@ -189,25 +174,18 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
189
174
|
const stims = stimsRes.rows;
|
|
190
175
|
if (stims.length === 0) return [];
|
|
191
176
|
const ids = stims.map((r) => r.stimulation_id);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
ids
|
|
196
|
-
]),
|
|
197
|
-
this.db.query(`SELECT stimulation_id, neuron_name, value
|
|
198
|
-
FROM ${p}stimulation_context WHERE stimulation_id = ANY($1)`, [
|
|
199
|
-
ids
|
|
200
|
-
])
|
|
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
|
|
201
180
|
]);
|
|
202
181
|
const frontierBy = groupBy(frontierRes.rows, (r) => r.stimulation_id);
|
|
203
|
-
const contextBy = groupBy(contextRes.rows, (r) => r.stimulation_id);
|
|
204
182
|
return stims.map((r) => {
|
|
205
|
-
var _a2
|
|
183
|
+
var _a2;
|
|
206
184
|
return {
|
|
207
185
|
stimulationId: r.stimulation_id,
|
|
208
186
|
entry: r.entry,
|
|
209
187
|
status: r.status,
|
|
210
|
-
progress: this.buildProgress((_a2 = frontierBy.get(r.stimulation_id)) != null ? _a2 : []
|
|
188
|
+
progress: this.buildProgress((_a2 = frontierBy.get(r.stimulation_id)) != null ? _a2 : []),
|
|
211
189
|
...r.scope_name != null ? {
|
|
212
190
|
scopeName: r.scope_name
|
|
213
191
|
} : {}
|
|
@@ -230,6 +208,116 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
230
208
|
]);
|
|
231
209
|
return res.rows.map(rowToTask);
|
|
232
210
|
}
|
|
211
|
+
// ── roster query capability (filter + keyset + facets pushed into SQL) ──
|
|
212
|
+
/**
|
|
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.
|
|
219
|
+
*/
|
|
220
|
+
async selectRoster(filter, opts) {
|
|
221
|
+
var _a, _b, _c, _d, _e, _f;
|
|
222
|
+
await this.ensure();
|
|
223
|
+
const { p } = this;
|
|
224
|
+
let curStarted = null;
|
|
225
|
+
let curId = null;
|
|
226
|
+
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 : "";
|
|
230
|
+
}
|
|
231
|
+
const params = [
|
|
232
|
+
(_a = filter.scopeName) != null ? _a : null,
|
|
233
|
+
(_b = filter.status) != null ? _b : null,
|
|
234
|
+
(_c = filter.entryCollateralName) != null ? _c : null,
|
|
235
|
+
(_d = filter.neuronName) != null ? _d : null,
|
|
236
|
+
filter.text ? filter.text.toLowerCase() : null,
|
|
237
|
+
(_e = filter.since) != null ? _e : null,
|
|
238
|
+
(_f = filter.until) != null ? _f : null,
|
|
239
|
+
curStarted,
|
|
240
|
+
curId
|
|
241
|
+
];
|
|
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}`;
|
|
282
|
+
const res = await this.db.query(sql, params);
|
|
283
|
+
const rows = res.rows;
|
|
284
|
+
const total = rows.length ? Number(rows[0].total_count) : 0;
|
|
285
|
+
const entries = rows.map((r) => {
|
|
286
|
+
var _a2, _b2;
|
|
287
|
+
return {
|
|
288
|
+
row: {
|
|
289
|
+
stimulationId: r.stimulation_id,
|
|
290
|
+
scopeName: (_a2 = r.scope_name) != null ? _a2 : void 0,
|
|
291
|
+
entry: r.entry,
|
|
292
|
+
status: r.status,
|
|
293
|
+
startedAt: Number(r.started_at),
|
|
294
|
+
completedAt: r.completed_at == null ? null : Number(r.completed_at),
|
|
295
|
+
hopCount: Number(r.hop_count),
|
|
296
|
+
hasError: r.has_error
|
|
297
|
+
},
|
|
298
|
+
neurons: (_b2 = r.neurons) != null ? _b2 : []
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
entries,
|
|
303
|
+
total
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async queryRoster(filter, opts) {
|
|
307
|
+
const { entries, total } = await this.selectRoster(filter, opts);
|
|
308
|
+
const rows = entries.map((e) => e.row);
|
|
309
|
+
const lastRow = rows[rows.length - 1];
|
|
310
|
+
const hasMore = rows.length >= opts.limit && rows.length < total;
|
|
311
|
+
return {
|
|
312
|
+
rows,
|
|
313
|
+
nextCursor: hasMore && lastRow ? encodeRosterCursor(lastRow) : null,
|
|
314
|
+
total
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async queryFacets(filter, opts) {
|
|
318
|
+
const { entries } = await this.selectRoster(filter);
|
|
319
|
+
return computeRosterFacets(entries, opts);
|
|
320
|
+
}
|
|
233
321
|
// ── lifecycle ──
|
|
234
322
|
async delete(stimulationId) {
|
|
235
323
|
await this.ensure();
|
|
@@ -247,11 +335,35 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
247
335
|
]);
|
|
248
336
|
});
|
|
249
337
|
}
|
|
338
|
+
/**
|
|
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.
|
|
344
|
+
*/
|
|
345
|
+
async deleteStale(olderThanMs) {
|
|
346
|
+
await this.ensure();
|
|
347
|
+
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
|
+
});
|
|
361
|
+
}
|
|
250
362
|
async close() {
|
|
251
363
|
if (this.ownsPool && this.pool) await this.pool.end();
|
|
252
364
|
}
|
|
253
365
|
// ── helpers ──
|
|
254
|
-
buildProgress(frontier
|
|
366
|
+
buildProgress(frontier) {
|
|
255
367
|
const tasks = [
|
|
256
368
|
...frontier
|
|
257
369
|
].sort((a, b) => a.ord - b.ord).map((f) => ({
|
|
@@ -261,11 +373,8 @@ var _CNSPostgresStimulationRepository = class _CNSPostgresStimulationRepository
|
|
|
261
373
|
input: f.input
|
|
262
374
|
} : {}
|
|
263
375
|
}));
|
|
264
|
-
const ctx = {};
|
|
265
|
-
for (const c of context) ctx[c.neuron_name] = c.value;
|
|
266
376
|
return {
|
|
267
|
-
tasks
|
|
268
|
-
context: ctx
|
|
377
|
+
tasks
|
|
269
378
|
};
|
|
270
379
|
}
|
|
271
380
|
};
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/CNSPostgresStimulationRepository.ts"],"sourcesContent":["/**\n * Postgres relational store for the CNStra stimulation/attempt/task durable model\n * (`ICNSStimulationRepository`). The stimulation's `progress` is NORMALISED: the\n * outstanding frontier into `<prefix>stimulation_frontier` (one row per task) and\n * per-neuron context into `<prefix>stimulation_context` — rebuilt into `TCNSProgress`\n * on read.\n *\n * Reuses the `IPgQueryable` seam so `pg` is an optional peer: inject any pg\n * client/pool, or pass a `connectionString` for the repo to own a Pool. Behaviourally\n * matches `CNSInMemoryStimulationRepository` (listStimulations newest-first, getAttempts\n * by attemptNumber, getTasks by index, delete cascades).\n */\nimport { Pool } from 'pg';\nimport type { ICNSStimulationRepository } from '@cnstra/persist';\nimport type {\n TCNSStimulationPersisted,\n TCNSStimulationAttemptPersisted,\n TCNSStimulationTaskPersisted,\n TCNSProgress,\n TCNSSerializedTask,\n TCNSSignalRef,\n TCNSStimulationStatus,\n TCNSStimulationTaskStatus,\n} from '@cnstra/persist-dto';\nimport {\n DEFAULT_PREFIX,\n assertSafePrefix,\n ensureSchema,\n type IPgQueryable,\n} from './migrate';\n\nexport interface TCNSPostgresStimulationRepositoryOptions {\n connectionString?: string;\n /** Inject an existing pg Pool (transactions supported; not closed by `close()`). */\n pool?: Pool;\n /** Inject any queryable (reads + best-effort non-transactional writes). */\n queryable?: IPgQueryable;\n /** Table prefix (default `cns_`). */\n tablePrefix?: string;\n /** Lazily ensure the schema on first use (default true). */\n autoMigrate?: boolean;\n}\n\nconst j = (v: unknown): string => JSON.stringify(v ?? null);\nconst num = (v: unknown): number => Number(v);\nconst numOrNull = (v: unknown): number | null => (v == null ? null : Number(v));\n\nexport class CNSPostgresStimulationRepository\n implements ICNSStimulationRepository\n{\n private readonly db: IPgQueryable;\n private readonly pool?: Pool;\n private readonly ownsPool: boolean;\n private readonly p: string;\n private readonly autoMigrate: boolean;\n private ready: Promise<void> | undefined;\n\n constructor(opts: TCNSPostgresStimulationRepositoryOptions) {\n if (opts.pool) {\n this.pool = opts.pool;\n this.db = opts.pool;\n this.ownsPool = false;\n } else if (opts.connectionString) {\n this.pool = new Pool({ connectionString: opts.connectionString });\n this.db = this.pool;\n this.ownsPool = true;\n } else if (opts.queryable) {\n this.db = opts.queryable;\n this.ownsPool = false;\n } else {\n throw new Error(\n '[@cnstra/persist-postgres] needs `connectionString`, `pool`, or `queryable`'\n );\n }\n this.p = assertSafePrefix(opts.tablePrefix ?? DEFAULT_PREFIX);\n this.autoMigrate = opts.autoMigrate ?? true;\n }\n\n private ensure(): Promise<void> {\n if (!this.autoMigrate) return Promise.resolve();\n return (this.ready ??= ensureSchema(this.db, this.p));\n }\n\n /** Run `fn` in a transaction when a Pool is available; else best-effort inline. */\n private async tx<T>(fn: (db: IPgQueryable) => Promise<T>): Promise<T> {\n if (!this.pool) return fn(this.db);\n const client = await this.pool.connect();\n try {\n await client.query('BEGIN');\n const out = await fn(client as unknown as IPgQueryable);\n await client.query('COMMIT');\n return out;\n } catch (e) {\n await client.query('ROLLBACK');\n throw e;\n } finally {\n client.release();\n }\n }\n\n // ── write ──\n\n async saveStimulation(s: TCNSStimulationPersisted): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n await db.query(\n `INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, updated_at)\n VALUES ($1, $2::jsonb, $3, $4, now())\n ON CONFLICT (stimulation_id)\n DO UPDATE SET entry = EXCLUDED.entry, status = EXCLUDED.status,\n scope_name = EXCLUDED.scope_name, updated_at = now()`,\n [s.stimulationId, j(s.entry), s.status, s.scopeName ?? null]\n );\n // Replace the normalised frontier.\n await db.query(\n `DELETE FROM ${p}stimulation_frontier WHERE stimulation_id = $1`,\n [s.stimulationId]\n );\n for (let i = 0; i < s.progress.tasks.length; i++) {\n const t = s.progress.tasks[i];\n await db.query(\n `INSERT INTO ${p}stimulation_frontier\n (stimulation_id, ord, neuron_name, dendrite_collateral_name, input)\n VALUES ($1, $2, $3, $4, $5::jsonb)`,\n [\n s.stimulationId,\n i,\n t.neuronName,\n t.dendriteCollateralName,\n t.input ? j(t.input) : null,\n ]\n );\n }\n // Replace the normalised context.\n await db.query(\n `DELETE FROM ${p}stimulation_context WHERE stimulation_id = $1`,\n [s.stimulationId]\n );\n for (const [neuronName, value] of Object.entries(\n s.progress.context\n )) {\n await db.query(\n `INSERT INTO ${p}stimulation_context (stimulation_id, neuron_name, value)\n VALUES ($1, $2, $3::jsonb)`,\n [s.stimulationId, neuronName, j(value)]\n );\n }\n });\n }\n\n async saveAttempt(a: TCNSStimulationAttemptPersisted): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}stimulation_attempt\n (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,\n completed_at, hop_count, has_error, replay_of, entry)\n VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)\n ON CONFLICT (stimulation_attempt_id) DO UPDATE SET\n status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,\n hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,\n replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`,\n [\n a.stimulationAttemptId,\n a.stimulationId,\n a.attemptNumber,\n a.status,\n a.startedAt,\n a.completedAt,\n a.hopCount,\n a.hasError,\n a.replayOf,\n j(a.entry),\n ]\n );\n }\n\n async appendTask(t: TCNSStimulationTaskPersisted): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}task\n (stimulation_attempt_id, \"index\", neuron_name, dendrite_collateral_name,\n input_index, output, status, error, started_at, duration)\n VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)\n ON CONFLICT (stimulation_attempt_id, \"index\") DO NOTHING`,\n [\n t.stimulationAttemptId,\n t.index,\n t.neuronName,\n t.dendriteCollateralName,\n t.inputIndex,\n t.output ? j(t.output) : null,\n t.status,\n t.error,\n t.startedAt,\n t.duration,\n ]\n );\n }\n\n // ── read ──\n\n async getStimulation(\n stimulationId: string\n ): Promise<TCNSStimulationPersisted | undefined> {\n await this.ensure();\n const { p } = this;\n const stimRes = await this.db.query(\n `SELECT entry, status, scope_name FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n const row = stimRes.rows[0] as\n | {\n entry: TCNSSignalRef;\n status: TCNSStimulationStatus;\n scope_name: string | null;\n }\n | undefined;\n if (!row) return undefined;\n const [frontier, context] = await Promise.all([\n this.db.query(\n `SELECT ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`,\n [stimulationId]\n ),\n this.db.query(\n `SELECT neuron_name, value FROM ${p}stimulation_context WHERE stimulation_id = $1`,\n [stimulationId]\n ),\n ]);\n return {\n stimulationId,\n entry: row.entry,\n status: row.status,\n progress: this.buildProgress(\n frontier.rows as TFrontierRow[],\n context.rows as TContextRow[]\n ),\n ...(row.scope_name != null ? { scopeName: row.scope_name } : {}),\n };\n }\n\n async listStimulations(filter?: {\n scopeName?: string;\n }): Promise<TCNSStimulationPersisted[]> {\n await this.ensure();\n const { p } = this;\n const scope = filter?.scopeName ?? null;\n const stimsRes = await this.db.query(\n `SELECT stimulation_id, entry, status, scope_name FROM ${p}stimulation\n WHERE ($1::text IS NULL OR scope_name = $1)\n ORDER BY updated_at DESC`,\n [scope]\n );\n const stims = stimsRes.rows as TStimulationRow[];\n if (stims.length === 0) return [];\n const ids = stims.map(r => r.stimulation_id);\n const [frontierRes, contextRes] = await Promise.all([\n this.db.query(\n `SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`,\n [ids]\n ),\n this.db.query(\n `SELECT stimulation_id, neuron_name, value\n FROM ${p}stimulation_context WHERE stimulation_id = ANY($1)`,\n [ids]\n ),\n ]);\n const frontierBy = groupBy(\n frontierRes.rows as TFrontierRow[],\n r => r.stimulation_id\n );\n const contextBy = groupBy(\n contextRes.rows as TContextRow[],\n r => r.stimulation_id\n );\n return stims.map(r => ({\n stimulationId: r.stimulation_id,\n entry: r.entry,\n status: r.status,\n progress: this.buildProgress(\n frontierBy.get(r.stimulation_id) ?? [],\n contextBy.get(r.stimulation_id) ?? []\n ),\n ...(r.scope_name != null ? { scopeName: r.scope_name } : {}),\n }));\n }\n\n async getAttempts(\n stimulationId: string\n ): Promise<TCNSStimulationAttemptPersisted[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}stimulation_attempt WHERE stimulation_id = $1 ORDER BY attempt_number`,\n [stimulationId]\n );\n return (res.rows as TAttemptRow[]).map(rowToAttempt);\n }\n\n async getTasks(\n stimulationAttemptId: string\n ): Promise<TCNSStimulationTaskPersisted[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}task WHERE stimulation_attempt_id = $1 ORDER BY \"index\"`,\n [stimulationAttemptId]\n );\n return (res.rows as TTaskRow[]).map(rowToTask);\n }\n\n // ── lifecycle ──\n\n async delete(stimulationId: string): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n // tasks + attempts have no FK (see migrate.ts) → delete them explicitly,\n // tasks first (they reference attempt ids), then the attempts.\n await db.query(\n `DELETE FROM ${p}task WHERE stimulation_attempt_id IN\n (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt WHERE stimulation_id = $1)`,\n [stimulationId]\n );\n await db.query(\n `DELETE FROM ${p}stimulation_attempt WHERE stimulation_id = $1`,\n [stimulationId]\n );\n // FK ON DELETE CASCADE removes frontier/context.\n await db.query(\n `DELETE FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n });\n }\n\n async close(): Promise<void> {\n if (this.ownsPool && this.pool) await this.pool.end();\n }\n\n // ── helpers ──\n\n private buildProgress(\n frontier: TFrontierRow[],\n context: TContextRow[]\n ): TCNSProgress {\n const tasks: TCNSSerializedTask[] = [...frontier]\n .sort((a, b) => a.ord - b.ord)\n .map(f => ({\n neuronName: f.neuron_name,\n dendriteCollateralName: f.dendrite_collateral_name,\n ...(f.input != null ? { input: f.input } : {}),\n }));\n const ctx: Record<string, unknown> = {};\n for (const c of context) ctx[c.neuron_name] = c.value;\n return { tasks, context: ctx };\n }\n}\n\n// ── row shapes (jsonb columns come back already parsed by node-pg) ──\ntype TStimulationRow = {\n stimulation_id: string;\n entry: TCNSSignalRef;\n status: TCNSStimulationStatus;\n scope_name: string | null;\n};\ntype TFrontierRow = {\n stimulation_id: string;\n ord: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input: { collateralName: string; payload?: unknown } | null;\n};\ntype TContextRow = {\n stimulation_id: string;\n neuron_name: string;\n value: unknown;\n};\ntype TAttemptRow = {\n stimulation_attempt_id: string;\n stimulation_id: string;\n attempt_number: number;\n status: TCNSStimulationStatus;\n started_at: string | number;\n completed_at: string | number | null;\n hop_count: number;\n has_error: boolean;\n replay_of: string | null;\n entry: TCNSSignalRef[];\n};\ntype TTaskRow = {\n stimulation_attempt_id: string;\n index: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input_index: number;\n output: TCNSSignalRef | null;\n status: TCNSStimulationTaskStatus;\n error: string | null;\n started_at: string | number;\n duration: number | null;\n};\n\nfunction rowToAttempt(a: TAttemptRow): TCNSStimulationAttemptPersisted {\n return {\n stimulationAttemptId: a.stimulation_attempt_id,\n stimulationId: a.stimulation_id,\n attemptNumber: num(a.attempt_number),\n status: a.status,\n startedAt: num(a.started_at),\n completedAt: numOrNull(a.completed_at),\n hopCount: num(a.hop_count),\n hasError: Boolean(a.has_error),\n replayOf: a.replay_of ?? null,\n entry: a.entry,\n };\n}\n\nfunction rowToTask(t: TTaskRow): TCNSStimulationTaskPersisted {\n return {\n stimulationAttemptId: t.stimulation_attempt_id,\n index: num(t.index),\n neuronName: t.neuron_name,\n dendriteCollateralName: t.dendrite_collateral_name,\n inputIndex: num(t.input_index),\n output: t.output ?? null,\n status: t.status,\n error: t.error ?? null,\n startedAt: num(t.started_at),\n duration: numOrNull(t.duration),\n };\n}\n\nfunction groupBy<T, K>(rows: T[], key: (r: T) => K): Map<K, T[]> {\n const m = new Map<K, T[]>();\n for (const r of rows) {\n const k = key(r);\n const b = m.get(k);\n if (b) b.push(r);\n else m.set(k, [r]);\n }\n return m;\n}\n"],"mappings":"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","names":["Pool","j","v","JSON","stringify","num","Number","numOrNull","CNSPostgresStimulationRepository","opts","db","pool","ownsPool","p","autoMigrate","ready","connectionString","Pool","queryable","Error","assertSafePrefix","tablePrefix","DEFAULT_PREFIX","ensure","Promise","resolve","ensureSchema","tx","fn","client","connect","query","out","e","release","saveStimulation","s","stimulationId","entry","status","scopeName","i","progress","tasks","length","t","neuronName","dendriteCollateralName","input","value","Object","entries","context","saveAttempt","a","stimulationAttemptId","attemptNumber","startedAt","completedAt","hopCount","hasError","replayOf","appendTask","index","inputIndex","output","error","duration","getStimulation","stimRes","row","rows","undefined","frontier","all","buildProgress","scope_name","listStimulations","filter","scope","stimsRes","stims","ids","map","r","stimulation_id","frontierRes","contextRes","frontierBy","groupBy","contextBy","_a","get","getAttempts","res","rowToAttempt","getTasks","rowToTask","delete","close","end","sort","b","ord","f","neuron_name","dendrite_collateral_name","ctx","c","stimulation_attempt_id","attempt_number","started_at","completed_at","hop_count","Boolean","has_error","replay_of","input_index","key","m","Map","k","push","set"]}
|
|
1
|
+
{"version":3,"sources":["../src/CNSPostgresStimulationRepository.ts"],"sourcesContent":["/**\n * Postgres relational store for the CNStra stimulation/attempt/task resumable model\n * (`ICNSStimulationRepository`). The stimulation's `progress` is NORMALISED: the\n * outstanding frontier into `<prefix>stimulation_frontier` (one row per task) —\n * rebuilt into `TCNSProgress` on read.\n *\n * Reuses the `IPgQueryable` seam so `pg` is an optional peer: inject any pg\n * client/pool, or pass a `connectionString` for the repo to own a Pool. Behaviourally\n * matches `CNSInMemoryStimulationRepository` (listStimulations newest-first, getAttempts\n * by attemptNumber, getTasks by index, delete cascades).\n */\nimport { Pool } from 'pg';\nimport type {\n ICNSStimulationRepository,\n ICNSStimulationRosterQuery,\n TCNSRosterEntry,\n} from '@cnstra/persist';\nimport { computeRosterFacets, encodeRosterCursor } from '@cnstra/persist';\nimport type {\n TCNSStimulationState,\n TCNSStimulationAttempt,\n TCNSStimulationTask,\n TCNSProgress,\n TCNSPlannedTaskJSON,\n TCNSSignalJSON,\n ECNSStimulationStatus,\n ECNSStimulationTaskStatus,\n TCNSStimulationRosterFilter,\n TCNSStimulationRosterRow,\n TCNSStimulationRosterPage,\n TCNSStimulationRosterFacets,\n} from '@cnstra/persist-dto';\nimport {\n DEFAULT_PREFIX,\n assertSafePrefix,\n ensureSchema,\n type IPgQueryable,\n} from './migrate';\n\nexport interface TCNSPostgresStimulationRepositoryOptions {\n connectionString?: string;\n /** Inject an existing pg Pool (transactions supported; not closed by `close()`). */\n pool?: Pool;\n /** Inject any queryable (reads + best-effort non-transactional writes). */\n queryable?: IPgQueryable;\n /** Table prefix (default `cns_`). */\n tablePrefix?: string;\n /** Lazily ensure the schema on first use (default true). */\n autoMigrate?: boolean;\n}\n\nconst j = (v: unknown): string => JSON.stringify(v ?? null);\nconst num = (v: unknown): number => Number(v);\nconst numOrNull = (v: unknown): number | null => (v == null ? null : Number(v));\n\nexport class CNSPostgresStimulationRepository\n implements ICNSStimulationRepository, ICNSStimulationRosterQuery\n{\n private readonly db: IPgQueryable;\n private readonly pool?: Pool;\n private readonly ownsPool: boolean;\n private readonly p: string;\n private readonly autoMigrate: boolean;\n private ready: Promise<void> | undefined;\n\n constructor(opts: TCNSPostgresStimulationRepositoryOptions) {\n if (opts.pool) {\n this.pool = opts.pool;\n this.db = opts.pool;\n this.ownsPool = false;\n } else if (opts.connectionString) {\n this.pool = new Pool({ connectionString: opts.connectionString });\n this.db = this.pool;\n this.ownsPool = true;\n } else if (opts.queryable) {\n this.db = opts.queryable;\n this.ownsPool = false;\n } else {\n throw new Error(\n '[@cnstra/persist-postgres] needs `connectionString`, `pool`, or `queryable`'\n );\n }\n this.p = assertSafePrefix(opts.tablePrefix ?? DEFAULT_PREFIX);\n this.autoMigrate = opts.autoMigrate ?? true;\n }\n\n private ensure(): Promise<void> {\n if (!this.autoMigrate) return Promise.resolve();\n return (this.ready ??= ensureSchema(this.db, this.p));\n }\n\n /** Run `fn` in a transaction when a Pool is available; else best-effort inline. */\n private async tx<T>(fn: (db: IPgQueryable) => Promise<T>): Promise<T> {\n if (!this.pool) return fn(this.db);\n const client = await this.pool.connect();\n try {\n await client.query('BEGIN');\n const out = await fn(client as unknown as IPgQueryable);\n await client.query('COMMIT');\n return out;\n } catch (e) {\n await client.query('ROLLBACK');\n throw e;\n } finally {\n client.release();\n }\n }\n\n // ── write ──\n\n async saveStimulation(s: TCNSStimulationState): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n await db.query(\n `INSERT INTO ${p}stimulation (stimulation_id, entry, status, scope_name, updated_at)\n VALUES ($1, $2::jsonb, $3, $4, now())\n ON CONFLICT (stimulation_id)\n DO UPDATE SET entry = EXCLUDED.entry, status = EXCLUDED.status,\n scope_name = EXCLUDED.scope_name, updated_at = now()`,\n [s.stimulationId, j(s.entry), s.status, s.scopeName ?? null]\n );\n // Replace the normalised frontier.\n await db.query(\n `DELETE FROM ${p}stimulation_frontier WHERE stimulation_id = $1`,\n [s.stimulationId]\n );\n for (let i = 0; i < s.progress.tasks.length; i++) {\n const t = s.progress.tasks[i];\n await db.query(\n `INSERT INTO ${p}stimulation_frontier\n (stimulation_id, ord, neuron_name, dendrite_collateral_name, input)\n VALUES ($1, $2, $3, $4, $5::jsonb)`,\n [\n s.stimulationId,\n i,\n t.neuronName,\n t.dendriteCollateralName,\n t.input ? j(t.input) : null,\n ]\n );\n }\n });\n }\n\n async saveAttempt(a: TCNSStimulationAttempt): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}stimulation_attempt\n (stimulation_attempt_id, stimulation_id, attempt_number, status, started_at,\n completed_at, hop_count, has_error, replay_of, entry)\n VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)\n ON CONFLICT (stimulation_attempt_id) DO UPDATE SET\n status = EXCLUDED.status, completed_at = EXCLUDED.completed_at,\n hop_count = EXCLUDED.hop_count, has_error = EXCLUDED.has_error,\n replay_of = EXCLUDED.replay_of, entry = EXCLUDED.entry`,\n [\n a.stimulationAttemptId,\n a.stimulationId,\n a.attemptNumber,\n a.status,\n a.startedAt,\n a.completedAt,\n a.hopCount,\n a.hasError,\n a.replayOf,\n j(a.entry),\n ]\n );\n }\n\n async appendTask(t: TCNSStimulationTask): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.db.query(\n `INSERT INTO ${p}task\n (stimulation_attempt_id, \"index\", neuron_name, dendrite_collateral_name,\n input_index, output, status, error, started_at, duration)\n VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10)\n ON CONFLICT (stimulation_attempt_id, \"index\") DO NOTHING`,\n [\n t.stimulationAttemptId,\n t.index,\n t.neuronName,\n t.dendriteCollateralName,\n t.inputIndex,\n t.output ? j(t.output) : null,\n t.status,\n t.error,\n t.startedAt,\n t.duration,\n ]\n );\n }\n\n // ── read ──\n\n async getStimulation(\n stimulationId: string\n ): Promise<TCNSStimulationState | undefined> {\n await this.ensure();\n const { p } = this;\n const stimRes = await this.db.query(\n `SELECT entry, status, scope_name FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n const row = stimRes.rows[0] as\n | {\n entry: TCNSSignalJSON;\n status: ECNSStimulationStatus;\n scope_name: string | null;\n }\n | undefined;\n if (!row) return undefined;\n const frontier = await this.db.query(\n `SELECT ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`,\n [stimulationId]\n );\n return {\n stimulationId,\n entry: row.entry,\n status: row.status,\n progress: this.buildProgress(frontier.rows as TFrontierRow[]),\n ...(row.scope_name != null ? { scopeName: row.scope_name } : {}),\n };\n }\n\n async listStimulations(filter?: {\n scopeName?: string;\n }): Promise<TCNSStimulationState[]> {\n await this.ensure();\n const { p } = this;\n const scope = filter?.scopeName ?? null;\n const stimsRes = await this.db.query(\n `SELECT stimulation_id, entry, status, scope_name FROM ${p}stimulation\n WHERE ($1::text IS NULL OR scope_name = $1)\n ORDER BY updated_at DESC`,\n [scope]\n );\n const stims = stimsRes.rows as TStimulationRow[];\n if (stims.length === 0) return [];\n const ids = stims.map(r => r.stimulation_id);\n const frontierRes = await this.db.query(\n `SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input\n FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`,\n [ids]\n );\n const frontierBy = groupBy(\n frontierRes.rows as TFrontierRow[],\n r => r.stimulation_id\n );\n return stims.map(r => ({\n stimulationId: r.stimulation_id,\n entry: r.entry,\n status: r.status,\n progress: this.buildProgress(\n frontierBy.get(r.stimulation_id) ?? []\n ),\n ...(r.scope_name != null ? { scopeName: r.scope_name } : {}),\n }));\n }\n\n async getAttempts(\n stimulationId: string\n ): Promise<TCNSStimulationAttempt[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}stimulation_attempt WHERE stimulation_id = $1 ORDER BY attempt_number`,\n [stimulationId]\n );\n return (res.rows as TAttemptRow[]).map(rowToAttempt);\n }\n\n async getTasks(\n stimulationAttemptId: string\n ): Promise<TCNSStimulationTask[]> {\n await this.ensure();\n const { p } = this;\n const res = await this.db.query(\n `SELECT * FROM ${p}task WHERE stimulation_attempt_id = $1 ORDER BY \"index\"`,\n [stimulationAttemptId]\n );\n return (res.rows as TTaskRow[]).map(rowToTask);\n }\n\n // ── roster query capability (filter + keyset + facets pushed into SQL) ──\n\n /**\n * Derive every filtered roster row in one CTE: `startedAt` = MIN(attempt),\n * `completedAt` = the latest attempt's, `hopCount` = SUM, `hasError` from\n * status, and the DISTINCT activated neuron names (for the neurons lens +\n * filter). Newest-first (`startedAt DESC, stimulationId DESC`) so the keyset\n * cursor is stable. `limit` is applied only when paginating (roster); facets\n * pull the whole filtered set.\n */\n private async selectRoster(\n filter: TCNSStimulationRosterFilter,\n opts?: { cursor?: string; limit?: number }\n ): Promise<{ entries: TCNSRosterEntry[]; total: number }> {\n await this.ensure();\n const { p } = this;\n // Decode the keyset cursor → the (startedAt, stimulationId) to page past.\n let curStarted: number | null = null;\n let curId: string | null = null;\n if (opts?.cursor) {\n const [ts, id] = Buffer.from(opts.cursor, 'base64')\n .toString('utf8')\n .split('|');\n curStarted = Number(ts);\n curId = id ?? '';\n }\n const params: unknown[] = [\n filter.scopeName ?? null,\n filter.status ?? null,\n filter.entryCollateralName ?? null,\n filter.neuronName ?? null,\n filter.text ? filter.text.toLowerCase() : null,\n filter.since ?? null,\n filter.until ?? null,\n curStarted,\n curId,\n ];\n const limitClause =\n opts?.limit != null ? ` LIMIT ${Math.max(0, opts.limit | 0)}` : '';\n const sql = `\n WITH agg AS (\n SELECT a.stimulation_id,\n MIN(a.started_at) AS started_at,\n SUM(a.hop_count) AS hop_count,\n (ARRAY_AGG(a.completed_at ORDER BY a.attempt_number DESC))[1] AS last_completed_at\n FROM ${p}stimulation_attempt a\n GROUP BY a.stimulation_id\n ),\n neu AS (\n SELECT ta.stimulation_id,\n ARRAY_AGG(DISTINCT t.neuron_name) AS neurons\n FROM ${p}task t\n JOIN ${p}stimulation_attempt ta\n ON ta.stimulation_attempt_id = t.stimulation_attempt_id\n GROUP BY ta.stimulation_id\n ),\n roster AS (\n SELECT s.stimulation_id, s.entry, s.status, s.scope_name,\n COALESCE(agg.started_at, 0) AS started_at,\n CASE WHEN s.status = 'running' THEN NULL\n ELSE agg.last_completed_at END AS completed_at,\n COALESCE(agg.hop_count, 0) AS hop_count,\n (s.status = 'failed') AS has_error,\n COALESCE(neu.neurons, ARRAY[]::text[]) AS neurons\n FROM ${p}stimulation s\n LEFT JOIN agg ON agg.stimulation_id = s.stimulation_id\n LEFT JOIN neu ON neu.stimulation_id = s.stimulation_id\n )\n SELECT *, COUNT(*) OVER() AS total_count FROM roster\n WHERE ($1::text IS NULL OR scope_name = $1)\n AND ($2::text IS NULL OR status = $2)\n AND ($3::text IS NULL OR entry->>'collateralName' = $3)\n AND ($4::text IS NULL OR $4 = ANY(neurons))\n AND ($5::text IS NULL OR lower(stimulation_id) LIKE '%'||$5||'%')\n AND ($6::bigint IS NULL OR started_at >= $6)\n AND ($7::bigint IS NULL OR started_at <= $7)\n AND ($8::bigint IS NULL OR (started_at, stimulation_id) < ($8, $9))\n ORDER BY started_at DESC, stimulation_id DESC${limitClause}`;\n const res = await this.db.query(sql, params);\n const rows = res.rows as TRosterRow[];\n const total = rows.length ? Number(rows[0].total_count) : 0;\n const entries: TCNSRosterEntry[] = rows.map(r => ({\n row: {\n stimulationId: r.stimulation_id,\n scopeName: r.scope_name ?? undefined,\n entry: r.entry as TCNSSignalJSON,\n status: r.status,\n startedAt: Number(r.started_at),\n completedAt: r.completed_at == null ? null : Number(r.completed_at),\n hopCount: Number(r.hop_count),\n hasError: r.has_error,\n },\n neurons: r.neurons ?? [],\n }));\n return { entries, total };\n }\n\n async queryRoster(\n filter: TCNSStimulationRosterFilter,\n opts: { cursor?: string; limit: number }\n ): Promise<TCNSStimulationRosterPage> {\n const { entries, total } = await this.selectRoster(filter, opts);\n const rows: TCNSStimulationRosterRow[] = entries.map(e => e.row);\n const lastRow = rows[rows.length - 1];\n // A full page implies there may be more; the window `total` is authoritative.\n const hasMore = rows.length >= opts.limit && rows.length < total;\n return {\n rows,\n nextCursor: hasMore && lastRow ? encodeRosterCursor(lastRow) : null,\n total,\n };\n }\n\n async queryFacets(\n filter: TCNSStimulationRosterFilter,\n opts?: { metricPath?: string }\n ): Promise<TCNSStimulationRosterFacets> {\n // Filter is pushed into SQL; the small GROUP-BY tally + histogram reuse the\n // shared reference aggregation so counts match the in-memory store exactly.\n const { entries } = await this.selectRoster(filter);\n return computeRosterFacets(entries, opts);\n }\n\n // ── lifecycle ──\n\n async delete(stimulationId: string): Promise<void> {\n await this.ensure();\n const { p } = this;\n await this.tx(async db => {\n // tasks + attempts have no FK (see migrate.ts) → delete them explicitly,\n // tasks first (they reference attempt ids), then the attempts.\n await db.query(\n `DELETE FROM ${p}task WHERE stimulation_attempt_id IN\n (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt WHERE stimulation_id = $1)`,\n [stimulationId]\n );\n await db.query(\n `DELETE FROM ${p}stimulation_attempt WHERE stimulation_id = $1`,\n [stimulationId]\n );\n // FK ON DELETE CASCADE removes the frontier.\n await db.query(\n `DELETE FROM ${p}stimulation WHERE stimulation_id = $1`,\n [stimulationId]\n );\n });\n }\n\n /**\n * Retention: prune stimulations not updated for `olderThanMs` (with their\n * attempts/tasks/frontier). A run's `updated_at` stops advancing once it\n * settles, so old finished runs age out while an active one stays fresh — the same\n * semantics as the in-memory store's `ttlMs` and the pg-boss checkpoint's\n * `deleteStale`. Run it on a schedule (cron). Returns the number removed.\n */\n async deleteStale(olderThanMs: number): Promise<number> {\n await this.ensure();\n const { p } = this;\n const params = [Math.max(0, Math.floor(olderThanMs))];\n const cond = `updated_at < now() - ($1::bigint * interval '1 millisecond')`;\n const staleIds = `SELECT stimulation_id FROM ${p}stimulation WHERE ${cond}`;\n return this.tx(async db => {\n // tasks + attempts have no FK → delete explicitly (tasks first), then the\n // stimulations (FK cascade removes the frontier).\n await db.query(\n `DELETE FROM ${p}task WHERE stimulation_attempt_id IN\n (SELECT stimulation_attempt_id FROM ${p}stimulation_attempt\n WHERE stimulation_id IN (${staleIds}))`,\n params\n );\n await db.query(\n `DELETE FROM ${p}stimulation_attempt WHERE stimulation_id IN (${staleIds})`,\n params\n );\n const res = await db.query(\n `DELETE FROM ${p}stimulation WHERE ${cond} RETURNING stimulation_id`,\n params\n );\n return res.rows.length;\n });\n }\n\n async close(): Promise<void> {\n if (this.ownsPool && this.pool) await this.pool.end();\n }\n\n // ── helpers ──\n\n private buildProgress(frontier: TFrontierRow[]): TCNSProgress {\n const tasks: TCNSPlannedTaskJSON[] = [...frontier]\n .sort((a, b) => a.ord - b.ord)\n .map(f => ({\n neuronName: f.neuron_name,\n dendriteCollateralName: f.dendrite_collateral_name,\n ...(f.input != null ? { input: f.input } : {}),\n }));\n return { tasks };\n }\n}\n\n// ── row shapes (jsonb columns come back already parsed by node-pg) ──\ntype TStimulationRow = {\n stimulation_id: string;\n entry: TCNSSignalJSON;\n status: ECNSStimulationStatus;\n scope_name: string | null;\n};\ntype TFrontierRow = {\n stimulation_id: string;\n ord: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input: TCNSSignalJSON | null;\n};\ntype TAttemptRow = {\n stimulation_attempt_id: string;\n stimulation_id: string;\n attempt_number: number;\n status: ECNSStimulationStatus;\n started_at: string | number;\n completed_at: string | number | null;\n hop_count: number;\n has_error: boolean;\n replay_of: string | null;\n entry: TCNSSignalJSON[];\n};\ntype TTaskRow = {\n stimulation_attempt_id: string;\n index: number;\n neuron_name: string;\n dendrite_collateral_name: string;\n input_index: number;\n output: TCNSSignalJSON | null;\n status: ECNSStimulationTaskStatus;\n error: string | null;\n started_at: string | number;\n duration: number | null;\n};\n/** One roster CTE row (see `selectRoster`), with the window total attached. */\ntype TRosterRow = {\n stimulation_id: string;\n entry: unknown;\n status: string;\n scope_name: string | null;\n started_at: string | number;\n completed_at: string | number | null;\n hop_count: string | number;\n has_error: boolean;\n neurons: string[] | null;\n total_count: string | number;\n};\n\nfunction rowToAttempt(a: TAttemptRow): TCNSStimulationAttempt {\n return {\n stimulationAttemptId: a.stimulation_attempt_id,\n stimulationId: a.stimulation_id,\n attemptNumber: num(a.attempt_number),\n status: a.status,\n startedAt: num(a.started_at),\n completedAt: numOrNull(a.completed_at),\n hopCount: num(a.hop_count),\n hasError: Boolean(a.has_error),\n replayOf: a.replay_of ?? null,\n entry: a.entry,\n };\n}\n\nfunction rowToTask(t: TTaskRow): TCNSStimulationTask {\n return {\n stimulationAttemptId: t.stimulation_attempt_id,\n index: num(t.index),\n neuronName: t.neuron_name,\n dendriteCollateralName: t.dendrite_collateral_name,\n inputIndex: num(t.input_index),\n output: t.output ?? null,\n status: t.status,\n error: t.error ?? null,\n startedAt: num(t.started_at),\n duration: numOrNull(t.duration),\n };\n}\n\nfunction groupBy<T, K>(rows: T[], key: (r: T) => K): Map<K, T[]> {\n const m = new Map<K, T[]>();\n for (const r of rows) {\n const k = key(r);\n const b = m.get(k);\n if (b) b.push(r);\n else m.set(k, [r]);\n }\n return m;\n}\n"],"mappings":"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","names":["Pool","computeRosterFacets","encodeRosterCursor","j","v","JSON","stringify","num","Number","numOrNull","CNSPostgresStimulationRepository","opts","db","pool","ownsPool","p","autoMigrate","ready","connectionString","Pool","queryable","Error","assertSafePrefix","tablePrefix","DEFAULT_PREFIX","ensure","Promise","resolve","ensureSchema","tx","fn","client","connect","query","out","e","release","saveStimulation","s","stimulationId","entry","status","scopeName","i","progress","tasks","length","t","neuronName","dendriteCollateralName","input","saveAttempt","a","stimulationAttemptId","attemptNumber","startedAt","completedAt","hopCount","hasError","replayOf","appendTask","index","inputIndex","output","error","duration","getStimulation","stimRes","row","rows","undefined","frontier","buildProgress","scope_name","listStimulations","filter","scope","stimsRes","stims","ids","map","r","stimulation_id","frontierRes","frontierBy","groupBy","_a","get","getAttempts","res","rowToAttempt","getTasks","rowToTask","selectRoster","curStarted","curId","cursor","ts","id","Buffer","from","toString","split","params","entryCollateralName","text","toLowerCase","since","until","limitClause","limit","Math","max","sql","total","total_count","entries","_b","started_at","completed_at","hop_count","has_error","neurons","queryRoster","lastRow","hasMore","nextCursor","encodeRosterCursor","queryFacets","computeRosterFacets","delete","deleteStale","olderThanMs","floor","cond","staleIds","close","end","sort","b","ord","f","neuron_name","dendrite_collateral_name","stimulation_attempt_id","attempt_number","Boolean","replay_of","input_index","key","m","Map","k","push","set"]}
|
package/package.json
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cnstra/persist-postgres",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Postgres relational store for CNStra
|
|
5
|
-
"keywords": [
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Postgres relational store for CNStra resumable execution (ICNSStimulationRepository: run/attempt/task) — owns its schema + migrations",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cnstra",
|
|
7
|
+
"postgres",
|
|
8
|
+
"resumable",
|
|
9
|
+
"run",
|
|
10
|
+
"attempt",
|
|
11
|
+
"task",
|
|
12
|
+
"persist"
|
|
13
|
+
],
|
|
6
14
|
"main": "dist/index.js",
|
|
7
15
|
"module": "dist/index.mjs",
|
|
8
16
|
"types": "dist/index.d.ts",
|
|
9
|
-
"exports": {
|
|
10
|
-
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.mjs",
|
|
21
|
+
"require": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"cnstra-persist-postgres": "./dist/cli.js"
|
|
26
|
+
},
|
|
11
27
|
"sideEffects": false,
|
|
12
|
-
"files": [
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
13
31
|
"scripts": {
|
|
32
|
+
"prepublishOnly": "node ../../scripts/check-drift.mjs --gate",
|
|
14
33
|
"build": "tsup",
|
|
15
34
|
"test": "jest",
|
|
16
35
|
"test:types": "tsc --noEmit",
|
|
17
36
|
"clean": "rm -rf dist"
|
|
18
37
|
},
|
|
19
38
|
"peerDependencies": {
|
|
20
|
-
"@cnstra/persist": "^0.
|
|
21
|
-
"@cnstra/persist-dto": "^0.
|
|
39
|
+
"@cnstra/persist": "^0.2.0",
|
|
40
|
+
"@cnstra/persist-dto": "^0.2.0",
|
|
22
41
|
"pg": ">=8"
|
|
23
42
|
},
|
|
24
|
-
"peerDependenciesMeta": {
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"pg": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
25
48
|
"devDependencies": {
|
|
26
|
-
"@cnstra/persist": "^0.
|
|
27
|
-
"@cnstra/persist-dto": "^0.
|
|
49
|
+
"@cnstra/persist": "^0.2.0",
|
|
50
|
+
"@cnstra/persist-dto": "^0.2.0",
|
|
28
51
|
"@types/jest": "29.5.12",
|
|
29
52
|
"@types/node": "^20.0.0",
|
|
30
53
|
"@types/pg": "^8.11.0",
|
|
@@ -36,5 +59,7 @@
|
|
|
36
59
|
},
|
|
37
60
|
"author": "Andrei Baikov <abaikov23@gmail.com>",
|
|
38
61
|
"license": "MIT",
|
|
39
|
-
"publishConfig": {
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
40
65
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/migrate.ts"],"sourcesContent":["/**\n * Schema / migrations for the CNStra stimulation/attempt/task relational store.\n *\n * The package OWNS this schema — run it explicitly via `cnstra-persist-postgres migrate`\n * or `ensureSchema(db)`, or let the repository auto-create it on first use. Five tables\n * under a configurable prefix (default `cns_`); the stimulation's `progress` is\n * NORMALISED into `stimulation_frontier` (one row per outstanding task) +\n * `stimulation_context` (one row per neuron).\n */\n\n/** Minimal pg client surface (a `pg` Pool or Client satisfies it). */\nexport interface IPgQueryable {\n query(text: string, params?: unknown[]): Promise<{ rows: unknown[] }>;\n}\n\nexport const DEFAULT_PREFIX = 'cns_';\n\n/** Guard the table prefix (it is interpolated into DDL/DML, not parameterised). */\nexport function assertSafePrefix(prefix: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {\n throw new Error(\n `[@cnstra/persist-postgres] unsafe table prefix: ${JSON.stringify(prefix)}`\n );\n }\n return prefix;\n}\n\n/** The full CREATE TABLE + CREATE INDEX DDL (idempotent). */\nexport function persistPostgresSchemaSql(prefix: string = DEFAULT_PREFIX): string {\n const p = assertSafePrefix(prefix);\n return `\nCREATE TABLE IF NOT EXISTS ${p}stimulation (\n stimulation_id text PRIMARY KEY,\n entry jsonb NOT NULL,\n status text NOT NULL,\n scope_name text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_updated_idx ON ${p}stimulation (updated_at DESC);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_scope_idx ON ${p}stimulation (scope_name);\n\nCREATE TABLE IF NOT EXISTS ${p}stimulation_frontier (\n stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,\n ord int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input jsonb,\n PRIMARY KEY (stimulation_id, ord)\n);\n\nCREATE TABLE IF NOT EXISTS ${p}stimulation_context (\n stimulation_id text NOT NULL REFERENCES ${p}stimulation(stimulation_id) ON DELETE CASCADE,\n neuron_name text NOT NULL,\n value jsonb NOT NULL,\n PRIMARY KEY (stimulation_id, neuron_name)\n);\n\n-- NOTE: no FK on stimulation_id — over the devtools wire the persistor's\n-- saveStimulation and saveAttempt ride SEPARATE async batches processed\n-- concurrently by the server, so an attempt insert can reach Postgres before its\n-- stimulation row's transaction commits; a hard FK would reject it (23503). Same\n-- exposure as ${p}task; delete() removes attempts explicitly instead of cascading.\nCREATE TABLE IF NOT EXISTS ${p}stimulation_attempt (\n stimulation_attempt_id text PRIMARY KEY,\n stimulation_id text NOT NULL,\n attempt_number int NOT NULL,\n status text NOT NULL,\n started_at bigint NOT NULL,\n completed_at bigint,\n hop_count int NOT NULL,\n has_error boolean NOT NULL,\n replay_of text,\n entry jsonb NOT NULL,\n UNIQUE (stimulation_id, attempt_number)\n);\nCREATE INDEX IF NOT EXISTS ${p}stimulation_attempt_idx ON ${p}stimulation_attempt (stimulation_id, attempt_number);\n\n-- NOTE: no FK on stimulation_attempt_id — the persistor streams settled tasks\n-- (appendTask) as hops complete, BEFORE the attempt marker is flushed (saveAttempt),\n-- so a hard FK would reject the insert. delete() removes tasks explicitly instead.\nCREATE TABLE IF NOT EXISTS ${p}task (\n stimulation_attempt_id text NOT NULL,\n \"index\" int NOT NULL,\n neuron_name text NOT NULL,\n dendrite_collateral_name text NOT NULL,\n input_index int NOT NULL,\n output jsonb,\n status text NOT NULL,\n error text,\n started_at bigint NOT NULL,\n duration int,\n PRIMARY KEY (stimulation_attempt_id, \"index\")\n);\n`;\n}\n\n/** Idempotently create all tables + indexes. Safe to run repeatedly. */\nexport async function ensureSchema(\n db: IPgQueryable,\n prefix: string = DEFAULT_PREFIX\n): Promise<void> {\n await db.query(persistPostgresSchemaSql(prefix));\n}\n"],"mappings":";;;;;;AAeO,IAAMA,iBAAiB;AAGvB,SAASC,iBAAiBC,QAAc;AAC3C,MAAI,CAAC,2BAA2BC,KAAKD,MAAAA,GAAS;AAC1C,UAAM,IAAIE,MACN,mDAAmDC,KAAKC,UAAUJ,MAAAA,CAAAA,EAAS;EAEnF;AACA,SAAOA;AACX;AAPgBD;AAUT,SAASM,yBAAyBL,SAAiBF,gBAAc;AACpE,QAAMQ,IAAIP,iBAAiBC,MAAAA;AAC3B,SAAO;6BACkBM,CAAAA;;;;;;;;6BAQAA,CAAAA,8BAA+BA,CAAAA;6BAC/BA,CAAAA,4BAA6BA,CAAAA;;6BAE7BA,CAAAA;8CACiBA,CAAAA;;;;;;;;6BAQjBA,CAAAA;8CACiBA,CAAAA;;;;;;;;;;iBAU7BA,CAAAA;6BACYA,CAAAA;;;;;;;;;;;;;6BAaAA,CAAAA,8BAA+BA,CAAAA;;;;;6BAK/BA,CAAAA;;;;;;;;;;;;;;AAc7B;AAnEgBD;AAsEhB,eAAsBE,aAClBC,IACAR,SAAiBF,gBAAc;AAE/B,QAAMU,GAAGC,MAAMJ,yBAAyBL,MAAAA,CAAAA;AAC5C;AALsBO;","names":["DEFAULT_PREFIX","assertSafePrefix","prefix","test","Error","JSON","stringify","persistPostgresSchemaSql","p","ensureSchema","db","query"]}
|