@cnstra/persist-postgres 0.1.0 → 0.2.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 CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  > **⚠️ Experimental**: API may change.
4
4
 
5
- A **Postgres relational store** for CNStra's durable run/attempt/task model —
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 durable-execution history **survive a
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-U4IKAHS4.mjs.map
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. 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":";;;;;AAOA,gBAAqB;;;ACQd,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;;;ADxFtB,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"]}
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
@@ -3,7 +3,7 @@ import {
3
3
  DEFAULT_PREFIX,
4
4
  __name,
5
5
  ensureSchema
6
- } from "./chunk-U4IKAHS4.mjs";
6
+ } from "./chunk-5FLIKV6I.mjs";
7
7
 
8
8
  // src/cli.ts
9
9
  import { Pool } from "pg";
package/dist/index.d.mts CHANGED
@@ -1,15 +1,14 @@
1
1
  import { Pool } from 'pg';
2
- import { ICNSStimulationRepository } from '@cnstra/persist';
3
- import { TCNSStimulationPersisted, TCNSStimulationAttemptPersisted, TCNSStimulationTaskPersisted } from '@cnstra/persist-dto';
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. Five tables
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 durable model
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) and
32
- * per-neuron context into `<prefix>stimulation_context` — rebuilt into `TCNSProgress`
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: TCNSStimulationPersisted): Promise<void>;
64
- saveAttempt(a: TCNSStimulationAttemptPersisted): Promise<void>;
65
- appendTask(t: TCNSStimulationTaskPersisted): Promise<void>;
66
- getStimulation(stimulationId: string): Promise<TCNSStimulationPersisted | undefined>;
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<TCNSStimulationPersisted[]>;
70
- getAttempts(stimulationId: string): Promise<TCNSStimulationAttemptPersisted[]>;
71
- getTasks(stimulationAttemptId: string): Promise<TCNSStimulationTaskPersisted[]>;
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 { TCNSStimulationPersisted, TCNSStimulationAttemptPersisted, TCNSStimulationTaskPersisted } from '@cnstra/persist-dto';
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. Five tables
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 durable model
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) and
32
- * per-neuron context into `<prefix>stimulation_context` — rebuilt into `TCNSProgress`
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: TCNSStimulationPersisted): Promise<void>;
64
- saveAttempt(a: TCNSStimulationAttemptPersisted): Promise<void>;
65
- appendTask(t: TCNSStimulationTaskPersisted): Promise<void>;
66
- getStimulation(stimulationId: string): Promise<TCNSStimulationPersisted | undefined>;
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<TCNSStimulationPersisted[]>;
70
- getAttempts(stimulationId: string): Promise<TCNSStimulationAttemptPersisted[]>;
71
- getTasks(stimulationAttemptId: string): Promise<TCNSStimulationTaskPersisted[]>;
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 [frontier, context] = await Promise.all([
269
- this.db.query(`SELECT ord, neuron_name, dendrite_collateral_name, input
270
- FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`, [
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, context.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 [frontierRes, contextRes] = await Promise.all([
301
- this.db.query(`SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input
302
- FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`, [
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, _b;
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 : [], (_b = contextBy.get(r.stimulation_id)) != null ? _b : []),
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, context) {
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":"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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","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":";;;;;;;;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;ACWA,gBAAqB;AAMrB,qBAAwD;;;ACHjD,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;;;AD3CtB,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;AA/DZ;AAkEQ,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;AAtFpC;AAuFQ,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,GAAwC;AAC1D,UAAM,KAAKb,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM,KAAKc,GAAG,OAAMjB,OAAAA;AAjH5B;AAkHY,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;IACJ,CAAA;EACJ;EAEA,MAAMC,YAAYC,GAA0C;AACxD,UAAM,KAAK3B,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM,KAAKH,GAAGqB,MACV,eAAelB,CAAAA;;;;;;;yEAQf;MACIqC,EAAEC;MACFD,EAAEb;MACFa,EAAEE;MACFF,EAAEX;MACFW,EAAEG;MACFH,EAAEI;MACFJ,EAAEK;MACFL,EAAEM;MACFN,EAAEO;MACFxD,EAAEiD,EAAEZ,KAAK;KACZ;EAET;EAEA,MAAMoB,WAAWb,GAAuC;AACpD,UAAM,KAAKtB,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM,KAAKH,GAAGqB,MACV,eAAelB,CAAAA;;;;wEAKf;MACIgC,EAAEM;MACFN,EAAEc;MACFd,EAAEC;MACFD,EAAEE;MACFF,EAAEe;MACFf,EAAEgB,SAAS5D,EAAE4C,EAAEgB,MAAM,IAAI;MACzBhB,EAAEN;MACFM,EAAEiB;MACFjB,EAAEQ;MACFR,EAAEkB;KACL;EAET;;EAIA,MAAMC,eACF3B,eACyC;AACzC,UAAM,KAAKd,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAMoD,UAAU,MAAM,KAAKvD,GAAGqB,MAC1B,yCAAyClB,CAAAA,yCACzC;MAACwB;KAAc;AAEnB,UAAM6B,MAAMD,QAAQE,KAAK,CAAA;AAOzB,QAAI,CAACD,IAAK,QAAOE;AACjB,UAAMC,WAAW,MAAM,KAAK3D,GAAGqB,MAC3B;oBACQlB,CAAAA,+DACR;MAACwB;KAAc;AAEnB,WAAO;MACHA;MACAC,OAAO4B,IAAI5B;MACXC,QAAQ2B,IAAI3B;MACZG,UAAU,KAAK4B,cAAcD,SAASF,IAAI;MAC1C,GAAID,IAAIK,cAAc,OAAO;QAAE/B,WAAW0B,IAAIK;MAAW,IAAI,CAAC;IAClE;EACJ;EAEA,MAAMC,iBAAiBC,QAEa;AAvOxC;AAwOQ,UAAM,KAAKlD,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM6D,SAAQD,sCAAQjC,cAARiC,YAAqB;AACnC,UAAME,WAAW,MAAM,KAAKjE,GAAGqB,MAC3B,yDAAyDlB,CAAAA;;wCAGzD;MAAC6D;KAAM;AAEX,UAAME,QAAQD,SAASR;AACvB,QAAIS,MAAMhC,WAAW,EAAG,QAAO,CAAA;AAC/B,UAAMiC,MAAMD,MAAME,IAAIC,CAAAA,MAAKA,EAAEC,cAAc;AAC3C,UAAMC,cAAc,MAAM,KAAKvE,GAAGqB,MAC9B;oBACQlB,CAAAA,oFACR;MAACgE;KAAI;AAET,UAAMK,aAAaC,QACfF,YAAYd,MACZY,CAAAA,MAAKA,EAAEC,cAAc;AAEzB,WAAOJ,MAAME,IAAIC,CAAAA,MAAAA;AA7PzB,UAAAK;AA6P+B;QACnB/C,eAAe0C,EAAEC;QACjB1C,OAAOyC,EAAEzC;QACTC,QAAQwC,EAAExC;QACVG,UAAU,KAAK4B,eACXY,MAAAA,WAAWG,IAAIN,EAAEC,cAAc,MAA/BE,OAAAA,MAAoC,CAAA,CAAE;QAE1C,GAAIH,EAAER,cAAc,OAAO;UAAE/B,WAAWuC,EAAER;QAAW,IAAI,CAAC;MAC9D;KAAA;EACJ;EAEA,MAAMe,YACFjD,eACiC;AACjC,UAAM,KAAKd,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM0E,MAAM,MAAM,KAAK7E,GAAGqB,MACtB,iBAAiBlB,CAAAA,yEACjB;MAACwB;KAAc;AAEnB,WAAQkD,IAAIpB,KAAuBW,IAAIU,YAAAA;EAC3C;EAEA,MAAMC,SACFtC,sBAC8B;AAC9B,UAAM,KAAK5B,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAM0E,MAAM,MAAM,KAAK7E,GAAGqB,MACtB,iBAAiBlB,CAAAA,2DACjB;MAACsC;KAAqB;AAE1B,WAAQoC,IAAIpB,KAAoBW,IAAIY,SAAAA;EACxC;;;;;;;;;;EAYA,MAAcC,aACVlB,QACAhE,MACsD;AA7S9D;AA8SQ,UAAM,KAAKc,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AAEd,QAAI+E,aAA4B;AAChC,QAAIC,QAAuB;AAC3B,QAAIpF,6BAAMqF,QAAQ;AACd,YAAM,CAACC,IAAIC,EAAAA,IAAMC,OAAOC,KAAKzF,KAAKqF,QAAQ,QAAA,EACrCK,SAAS,MAAA,EACTC,MAAM,GAAA;AACXR,mBAAatF,OAAOyF,EAAAA;AACpBF,cAAQG,kBAAM;IAClB;AACA,UAAMK,SAAoB;OACtB5B,YAAOjC,cAAPiC,YAAoB;OACpBA,YAAOlC,WAAPkC,YAAiB;OACjBA,YAAO6B,wBAAP7B,YAA8B;OAC9BA,YAAO3B,eAAP2B,YAAqB;MACrBA,OAAO8B,OAAO9B,OAAO8B,KAAKC,YAAW,IAAK;OAC1C/B,YAAOgC,UAAPhC,YAAgB;OAChBA,YAAOiC,UAAPjC,YAAgB;MAChBmB;MACAC;;AAEJ,UAAMc,eACFlG,6BAAMmG,UAAS,OAAO,UAAUC,KAAKC,IAAI,GAAGrG,KAAKmG,QAAQ,CAAA,CAAA,KAAO;AACpE,UAAMG,MAAM;;;;;;uBAMGlG,CAAAA;;;;;;uBAMAA,CAAAA;uBACAA,CAAAA;;;;;;;;;;;;uBAYAA,CAAAA;;;;;;;;;;;;;2DAaoC8F,WAAAA;AACnD,UAAMpB,MAAM,MAAM,KAAK7E,GAAGqB,MAAMgF,KAAKV,MAAAA;AACrC,UAAMlC,OAAOoB,IAAIpB;AACjB,UAAM6C,QAAQ7C,KAAKvB,SAAStC,OAAO6D,KAAK,CAAA,EAAG8C,WAAW,IAAI;AAC1D,UAAMC,UAA6B/C,KAAKW,IAAIC,CAAAA,MAAAA;AAjXpD,UAAAK,KAAA+B;AAiX0D;QAC9CjD,KAAK;UACD7B,eAAe0C,EAAEC;UACjBxC,YAAWuC,MAAAA,EAAER,eAAFQ,OAAAA,MAAgBX;UAC3B9B,OAAOyC,EAAEzC;UACTC,QAAQwC,EAAExC;UACVc,WAAW/C,OAAOyE,EAAEqC,UAAU;UAC9B9D,aAAayB,EAAEsC,gBAAgB,OAAO,OAAO/G,OAAOyE,EAAEsC,YAAY;UAClE9D,UAAUjD,OAAOyE,EAAEuC,SAAS;UAC5B9D,UAAUuB,EAAEwC;QAChB;QACAC,UAASzC,MAAAA,EAAEyC,YAAFzC,OAAAA,MAAa,CAAA;MAC1B;KAAA;AACA,WAAO;MAAEmC;MAASF;IAAM;EAC5B;EAEA,MAAMS,YACFhD,QACAhE,MACkC;AAClC,UAAM,EAAEyG,SAASF,MAAK,IAAK,MAAM,KAAKrB,aAAalB,QAAQhE,IAAAA;AAC3D,UAAM0D,OAAmC+C,QAAQpC,IAAI7C,CAAAA,MAAKA,EAAEiC,GAAG;AAC/D,UAAMwD,UAAUvD,KAAKA,KAAKvB,SAAS,CAAA;AAEnC,UAAM+E,UAAUxD,KAAKvB,UAAUnC,KAAKmG,SAASzC,KAAKvB,SAASoE;AAC3D,WAAO;MACH7C;MACAyD,YAAYD,WAAWD,cAAUG,mCAAmBH,OAAAA,IAAW;MAC/DV;IACJ;EACJ;EAEA,MAAMc,YACFrD,QACAhE,MACoC;AAGpC,UAAM,EAAEyG,QAAO,IAAK,MAAM,KAAKvB,aAAalB,MAAAA;AAC5C,eAAOsD,oCAAoBb,SAASzG,IAAAA;EACxC;;EAIA,MAAMuH,OAAO3F,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;;;;;;;;EASA,MAAM4F,YAAYC,aAAsC;AACpD,UAAM,KAAK3G,OAAM;AACjB,UAAM,EAAEV,EAAC,IAAK;AACd,UAAMwF,SAAS;MAACQ,KAAKC,IAAI,GAAGD,KAAKsB,MAAMD,WAAAA,CAAAA;;AACvC,UAAME,OAAO;AACb,UAAMC,WAAW,8BAA8BxH,CAAAA,qBAAsBuH,IAAAA;AACrE,WAAO,KAAKzG,GAAG,OAAMjB,OAAAA;AAGjB,YAAMA,GAAGqB,MACL,eAAelB,CAAAA;0DAC2BA,CAAAA;gDACVwH,QAAAA,MAChChC,MAAAA;AAEJ,YAAM3F,GAAGqB,MACL,eAAelB,CAAAA,gDAAiDwH,QAAAA,KAChEhC,MAAAA;AAEJ,YAAMd,MAAM,MAAM7E,GAAGqB,MACjB,eAAelB,CAAAA,qBAAsBuH,IAAAA,6BACrC/B,MAAAA;AAEJ,aAAOd,IAAIpB,KAAKvB;IACpB,CAAA;EACJ;EAEA,MAAM0F,QAAuB;AACzB,QAAI,KAAK1H,YAAY,KAAKD,KAAM,OAAM,KAAKA,KAAK4H,IAAG;EACvD;;EAIQjE,cAAcD,UAAwC;AAC1D,UAAM1B,QAA+B;SAAI0B;MACpCmE,KAAK,CAACtF,GAAGuF,MAAMvF,EAAEwF,MAAMD,EAAEC,GAAG,EAC5B5D,IAAI6D,CAAAA,OAAM;MACP7F,YAAY6F,EAAEC;MACd7F,wBAAwB4F,EAAEE;MAC1B,GAAIF,EAAE3F,SAAS,OAAO;QAAEA,OAAO2F,EAAE3F;MAAM,IAAI,CAAC;IAChD,EAAA;AACJ,WAAO;MAAEL;IAAM;EACnB;AACJ;AA/aanC;AAAN,IAAMA,mCAAN;AAqeP,SAASgF,aAAatC,GAAc;AA5hBpC;AA6hBI,SAAO;IACHC,sBAAsBD,EAAE4F;IACxBzG,eAAea,EAAE8B;IACjB5B,eAAe/C,IAAI6C,EAAE6F,cAAc;IACnCxG,QAAQW,EAAEX;IACVc,WAAWhD,IAAI6C,EAAEkE,UAAU;IAC3B9D,aAAa/C,UAAU2C,EAAEmE,YAAY;IACrC9D,UAAUlD,IAAI6C,EAAEoE,SAAS;IACzB9D,UAAUwF,QAAQ9F,EAAEqE,SAAS;IAC7B9D,WAAUP,OAAE+F,cAAF/F,YAAe;IACzBZ,OAAOY,EAAEZ;EACb;AACJ;AAbSkD;AAeT,SAASE,UAAU7C,GAAW;AA3iB9B;AA4iBI,SAAO;IACHM,sBAAsBN,EAAEiG;IACxBnF,OAAOtD,IAAIwC,EAAEc,KAAK;IAClBb,YAAYD,EAAE+F;IACd7F,wBAAwBF,EAAEgG;IAC1BjF,YAAYvD,IAAIwC,EAAEqG,WAAW;IAC7BrF,SAAQhB,OAAEgB,WAAFhB,YAAY;IACpBN,QAAQM,EAAEN;IACVuB,QAAOjB,OAAEiB,UAAFjB,YAAW;IAClBQ,WAAWhD,IAAIwC,EAAEuE,UAAU;IAC3BrD,UAAUxD,UAAUsC,EAAEkB,QAAQ;EAClC;AACJ;AAbS2B;AAeT,SAASP,QAAchB,MAAWgF,KAAgB;AAC9C,QAAMC,IAAI,oBAAIC,IAAAA;AACd,aAAWtE,KAAKZ,MAAM;AAClB,UAAMmF,IAAIH,IAAIpE,CAAAA;AACd,UAAM0D,IAAIW,EAAE/D,IAAIiE,CAAAA;AAChB,QAAIb,EAAGA,GAAEc,KAAKxE,CAAAA;QACTqE,GAAEI,IAAIF,GAAG;MAACvE;KAAE;EACrB;AACA,SAAOqE;AACX;AATSjE;","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-U4IKAHS4.mjs";
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 [frontier, context] = await Promise.all([
161
- this.db.query(`SELECT ord, neuron_name, dendrite_collateral_name, input
162
- FROM ${p}stimulation_frontier WHERE stimulation_id = $1 ORDER BY ord`, [
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, context.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 [frontierRes, contextRes] = await Promise.all([
193
- this.db.query(`SELECT stimulation_id, ord, neuron_name, dendrite_collateral_name, input
194
- FROM ${p}stimulation_frontier WHERE stimulation_id = ANY($1) ORDER BY stimulation_id, ord`, [
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, _b;
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 : [], (_b = contextBy.get(r.stimulation_id)) != null ? _b : []),
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, context) {
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
  };
@@ -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,15 +1,33 @@
1
1
  {
2
2
  "name": "@cnstra/persist-postgres",
3
- "version": "0.1.0",
4
- "description": "Postgres relational store for CNStra durable execution (ICNSStimulationRepository: run/attempt/task) — owns its schema + migrations",
5
- "keywords": ["cnstra", "postgres", "durable", "run", "attempt", "task", "persist"],
3
+ "version": "0.2.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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } },
10
- "bin": { "cnstra-persist-postgres": "./dist/cli.js" },
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": ["dist"],
28
+ "files": [
29
+ "dist"
30
+ ],
13
31
  "scripts": {
14
32
  "build": "tsup",
15
33
  "test": "jest",
@@ -21,7 +39,11 @@
21
39
  "@cnstra/persist-dto": "^0.1.0",
22
40
  "pg": ">=8"
23
41
  },
24
- "peerDependenciesMeta": { "pg": { "optional": true } },
42
+ "peerDependenciesMeta": {
43
+ "pg": {
44
+ "optional": true
45
+ }
46
+ },
25
47
  "devDependencies": {
26
48
  "@cnstra/persist": "^0.1.0",
27
49
  "@cnstra/persist-dto": "^0.1.0",
@@ -36,5 +58,7 @@
36
58
  },
37
59
  "author": "Andrei Baikov <abaikov23@gmail.com>",
38
60
  "license": "MIT",
39
- "publishConfig": { "access": "public" }
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
40
64
  }
@@ -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"]}