@cybernetyx1/atlasflow-postgres 0.1.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/LICENSE +18 -0
- package/README.md +39 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +630 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
PROPRIETARY SOFTWARE LICENSE
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cybernetyx. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software and its source code are the proprietary and confidential property
|
|
6
|
+
of the copyright holder. The software is original work authored independently.
|
|
7
|
+
|
|
8
|
+
No part of this software may be copied, reproduced, modified, published,
|
|
9
|
+
distributed, sublicensed, or sold in any form or by any means without the prior
|
|
10
|
+
written permission of the copyright holder, except as expressly permitted by a
|
|
11
|
+
separate written agreement.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
15
|
+
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT
|
|
16
|
+
HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
17
|
+
OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE
|
|
18
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @cybernetyx1/atlasflow-postgres
|
|
2
|
+
|
|
3
|
+
PostgreSQL persistence adapter for AtlasFlow sessions, runs, and durable streams — durable state that survives restarts.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @cybernetyx1/atlasflow-postgres
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Part of the AtlasFlow monorepo. Proprietary.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
`postgres(connectionString, options)` connects via the `postgres` driver and returns a persistence adapter. Call `migrate()` once on startup to create the schema, then pass the adapter into the runtime.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { postgres } from "@cybernetyx1/atlasflow-postgres";
|
|
19
|
+
|
|
20
|
+
const persistence = postgres(process.env.POSTGRES_URL!);
|
|
21
|
+
await persistence.migrate(); // idempotent — creates tables if missing
|
|
22
|
+
|
|
23
|
+
// pass `persistence` into the generated server / runtime as the durable store
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
To use a different client (for example PGlite in tests), build the adapter directly:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { postgresAdapter } from "@cybernetyx1/atlasflow-postgres";
|
|
30
|
+
|
|
31
|
+
const persistence = postgresAdapter(mySqlClient);
|
|
32
|
+
await persistence.migrate();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Exports: `postgres`, `postgresAdapter`, and the `SqlClient` / `PostgresPersistence` types. The returned adapter also exposes `migrate()` and `close()`.
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
Proprietary. © 2026 Cybernetyx. See LICENSE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { PersistenceAdapter, PersistenceAdapterOptions } from '@cybernetyx1/atlasflow-runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @cybernetyx1/atlasflow-postgres — durable PersistenceAdapter backed by PostgreSQL.
|
|
5
|
+
*
|
|
6
|
+
* import { postgres } from "@cybernetyx1/atlasflow-postgres";
|
|
7
|
+
* const persistence = postgres(process.env.DATABASE_URL);
|
|
8
|
+
* await persistence.migrate();
|
|
9
|
+
*
|
|
10
|
+
* Written against a minimal SqlClient ($1-style placeholders), so the same
|
|
11
|
+
* adapter runs on the `postgres` driver in production and on PGlite in tests.
|
|
12
|
+
* JSON payloads are stored as text and (de)serialized here for portability.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface SqlClient {
|
|
16
|
+
query(sql: string, params?: unknown[]): Promise<{
|
|
17
|
+
rows: Record<string, unknown>[];
|
|
18
|
+
}>;
|
|
19
|
+
end?(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
type PostgresPersistence = PersistenceAdapter & {
|
|
22
|
+
/** Create tables if they don't exist. Call once at startup. */
|
|
23
|
+
migrate(): Promise<void>;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
/** Build a PersistenceAdapter from any SqlClient (e.g. PGlite in tests). */
|
|
27
|
+
declare function postgresAdapter(client: SqlClient, options?: PersistenceAdapterOptions): PostgresPersistence;
|
|
28
|
+
/** Connect to PostgreSQL via the `postgres` driver and build the adapter. */
|
|
29
|
+
declare function postgres(connectionString: string, options?: PersistenceAdapterOptions): PostgresPersistence;
|
|
30
|
+
|
|
31
|
+
export { type PostgresPersistence, type SqlClient, postgres, postgresAdapter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import postgresDriver from "postgres";
|
|
3
|
+
import { DurableStreamSubscriptionStoreBase } from "@cybernetyx1/atlasflow-runtime";
|
|
4
|
+
var PgSessionStore = class {
|
|
5
|
+
constructor(sql) {
|
|
6
|
+
this.sql = sql;
|
|
7
|
+
}
|
|
8
|
+
sql;
|
|
9
|
+
async get(key) {
|
|
10
|
+
const { rows } = await this.sql.query("SELECT data FROM atlasflow_sessions WHERE key = $1", [key]);
|
|
11
|
+
const row = rows[0];
|
|
12
|
+
return row ? JSON.parse(row.data) : null;
|
|
13
|
+
}
|
|
14
|
+
async put(key, data) {
|
|
15
|
+
await this.sql.query(
|
|
16
|
+
"INSERT INTO atlasflow_sessions (key, data, updated_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at",
|
|
17
|
+
[key, JSON.stringify(data), data.updatedAt]
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
async delete(key) {
|
|
21
|
+
await this.sql.query("DELETE FROM atlasflow_sessions WHERE key = $1", [key]);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var PgRunStore = class {
|
|
25
|
+
constructor(sql) {
|
|
26
|
+
this.sql = sql;
|
|
27
|
+
}
|
|
28
|
+
sql;
|
|
29
|
+
async create(record) {
|
|
30
|
+
await this.sql.query(
|
|
31
|
+
`INSERT INTO atlasflow_runs (
|
|
32
|
+
run_id, agent, instance_id, status, result, error, started_at, ended_at,
|
|
33
|
+
message, payload, images, kind, state, lease_owner, lease_acquired_at, lease_expires_at
|
|
34
|
+
)
|
|
35
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
|
|
36
|
+
[
|
|
37
|
+
record.runId,
|
|
38
|
+
record.agent,
|
|
39
|
+
record.instanceId,
|
|
40
|
+
record.status,
|
|
41
|
+
json(record.result),
|
|
42
|
+
json(record.error),
|
|
43
|
+
timestampOrNow(record.startedAt),
|
|
44
|
+
nullableTimestamp(record.endedAt),
|
|
45
|
+
record.message ?? null,
|
|
46
|
+
json(record.payload),
|
|
47
|
+
json(record.images),
|
|
48
|
+
record.kind ?? null,
|
|
49
|
+
json(record.state),
|
|
50
|
+
record.leaseOwner ?? null,
|
|
51
|
+
nullableTimestamp(record.leaseAcquiredAt),
|
|
52
|
+
nullableTimestamp(record.leaseExpiresAt)
|
|
53
|
+
]
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
async update(runId, patch) {
|
|
57
|
+
const columns = {};
|
|
58
|
+
if (patch.status !== void 0) columns.status = patch.status;
|
|
59
|
+
if (patch.result !== void 0) columns.result = json(patch.result);
|
|
60
|
+
if (patch.error !== void 0) columns.error = json(patch.error);
|
|
61
|
+
if (patch.endedAt !== void 0) columns.ended_at = patch.endedAt;
|
|
62
|
+
if (patch.message !== void 0) columns.message = patch.message;
|
|
63
|
+
if (patch.payload !== void 0) columns.payload = json(patch.payload);
|
|
64
|
+
if (patch.images !== void 0) columns.images = json(patch.images);
|
|
65
|
+
if (patch.kind !== void 0) columns.kind = patch.kind;
|
|
66
|
+
if (patch.state !== void 0) columns.state = json(patch.state);
|
|
67
|
+
if (patch.leaseOwner !== void 0) columns.lease_owner = patch.leaseOwner;
|
|
68
|
+
if (patch.leaseAcquiredAt !== void 0) columns.lease_acquired_at = patch.leaseAcquiredAt;
|
|
69
|
+
if (patch.leaseExpiresAt !== void 0) columns.lease_expires_at = patch.leaseExpiresAt;
|
|
70
|
+
const keys = Object.keys(columns);
|
|
71
|
+
if (keys.length === 0) return;
|
|
72
|
+
const sets = keys.map((k, i) => `${k}=$${i + 2}`).join(", ");
|
|
73
|
+
await this.sql.query(`UPDATE atlasflow_runs SET ${sets} WHERE run_id=$1`, [runId, ...keys.map((k) => columns[k])]);
|
|
74
|
+
}
|
|
75
|
+
async get(runId) {
|
|
76
|
+
const { rows } = await this.sql.query("SELECT * FROM atlasflow_runs WHERE run_id = $1", [runId]);
|
|
77
|
+
return rows[0] ? rowToRun(rows[0]) : null;
|
|
78
|
+
}
|
|
79
|
+
async list(filter) {
|
|
80
|
+
const conds = [];
|
|
81
|
+
const params = [];
|
|
82
|
+
if (filter?.status) {
|
|
83
|
+
params.push(filter.status);
|
|
84
|
+
conds.push(`status = $${params.length}`);
|
|
85
|
+
}
|
|
86
|
+
if (filter?.agent) {
|
|
87
|
+
params.push(filter.agent);
|
|
88
|
+
conds.push(`agent = $${params.length}`);
|
|
89
|
+
}
|
|
90
|
+
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
|
91
|
+
const limit = filter?.limit ? `LIMIT ${Math.floor(filter.limit)}` : "";
|
|
92
|
+
const { rows } = await this.sql.query(`SELECT * FROM atlasflow_runs ${where} ORDER BY started_at DESC ${limit}`, params);
|
|
93
|
+
return rows.map(rowToRun);
|
|
94
|
+
}
|
|
95
|
+
async claimLease(runId, owner, now, ttlMs) {
|
|
96
|
+
const { rows } = await this.sql.query(
|
|
97
|
+
`UPDATE atlasflow_runs
|
|
98
|
+
SET lease_owner = $2, lease_acquired_at = $3, lease_expires_at = $4
|
|
99
|
+
WHERE run_id = $1
|
|
100
|
+
AND status = 'running'
|
|
101
|
+
AND (
|
|
102
|
+
lease_owner IS NULL
|
|
103
|
+
OR lease_owner = $2
|
|
104
|
+
OR lease_expires_at IS NULL
|
|
105
|
+
OR lease_expires_at <= $3
|
|
106
|
+
)
|
|
107
|
+
RETURNING run_id`,
|
|
108
|
+
[runId, owner, now, now + ttlMs]
|
|
109
|
+
);
|
|
110
|
+
return rows.length > 0;
|
|
111
|
+
}
|
|
112
|
+
async claimQueued(runId, owner, now, ttlMs) {
|
|
113
|
+
const { rows } = await this.sql.query(
|
|
114
|
+
`UPDATE atlasflow_runs
|
|
115
|
+
SET status = 'running', lease_owner = $2, lease_acquired_at = $3, lease_expires_at = $4
|
|
116
|
+
WHERE run_id = $1 AND status = 'queued'
|
|
117
|
+
RETURNING run_id`,
|
|
118
|
+
[runId, owner, now, now + ttlMs]
|
|
119
|
+
);
|
|
120
|
+
return rows.length > 0;
|
|
121
|
+
}
|
|
122
|
+
async heartbeatLease(runId, owner, now, ttlMs) {
|
|
123
|
+
const { rows } = await this.sql.query(
|
|
124
|
+
`UPDATE atlasflow_runs
|
|
125
|
+
SET lease_expires_at = $3
|
|
126
|
+
WHERE run_id = $1 AND status = 'running' AND lease_owner = $2
|
|
127
|
+
RETURNING run_id`,
|
|
128
|
+
[runId, owner, now + ttlMs]
|
|
129
|
+
);
|
|
130
|
+
return rows.length > 0;
|
|
131
|
+
}
|
|
132
|
+
async releaseLease(runId, owner) {
|
|
133
|
+
await this.sql.query(
|
|
134
|
+
`UPDATE atlasflow_runs
|
|
135
|
+
SET lease_owner = NULL, lease_acquired_at = NULL, lease_expires_at = NULL
|
|
136
|
+
WHERE run_id = $1 AND lease_owner = $2`,
|
|
137
|
+
[runId, owner]
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
async appendEvent(runId, event) {
|
|
141
|
+
await this.sql.query(
|
|
142
|
+
"INSERT INTO atlasflow_run_events (run_id, idx, event) VALUES ($1, $2, $3) ON CONFLICT (run_id, idx) DO NOTHING",
|
|
143
|
+
[runId, event.index, JSON.stringify(event)]
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
async events(runId) {
|
|
147
|
+
const { rows } = await this.sql.query("SELECT event FROM atlasflow_run_events WHERE run_id = $1 ORDER BY idx ASC", [runId]);
|
|
148
|
+
return rows.map((r) => JSON.parse(r.event));
|
|
149
|
+
}
|
|
150
|
+
async eventCount(runId) {
|
|
151
|
+
const { rows } = await this.sql.query("SELECT COUNT(*) AS n FROM atlasflow_run_events WHERE run_id = $1", [runId]);
|
|
152
|
+
return Number(rows[0]?.n ?? 0);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
var PgStreamStore = class {
|
|
156
|
+
constructor(sql) {
|
|
157
|
+
this.sql = sql;
|
|
158
|
+
}
|
|
159
|
+
sql;
|
|
160
|
+
async create(path, opts = {}) {
|
|
161
|
+
const existing = await this.get(path);
|
|
162
|
+
if (existing) {
|
|
163
|
+
const contentType2 = opts.contentType === void 0 ? existing.contentType : normalizeStreamContentType(opts.contentType);
|
|
164
|
+
if (normalizeStreamContentType(existing.contentType) !== contentType2 || opts.ttlSeconds !== existing.ttlSeconds || opts.expiresAt !== existing.expiresAt || (opts.initialData?.length ?? 0) > 0) {
|
|
165
|
+
throw new Error("stream already exists with different configuration");
|
|
166
|
+
}
|
|
167
|
+
return { record: existing, created: false };
|
|
168
|
+
}
|
|
169
|
+
let contentType = normalizeStreamContentType(opts.contentType);
|
|
170
|
+
let currentOffset = "-1";
|
|
171
|
+
let inheritedMessages = [];
|
|
172
|
+
if (opts.fork) {
|
|
173
|
+
const source = await this.get(opts.fork.path);
|
|
174
|
+
if (!source) throw new Error("fork source not found");
|
|
175
|
+
contentType = resolveForkContentType(source, opts.contentType);
|
|
176
|
+
currentOffset = resolveForkOffset(source, opts.fork.offset);
|
|
177
|
+
const forkIndex = parseStreamMessageIndex(currentOffset);
|
|
178
|
+
inheritedMessages = (await this.messages(opts.fork.path)).filter((message) => message.index <= forkIndex);
|
|
179
|
+
}
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
let record = {
|
|
182
|
+
path,
|
|
183
|
+
contentType,
|
|
184
|
+
closed: opts.closed === true,
|
|
185
|
+
currentOffset,
|
|
186
|
+
createdAt: now,
|
|
187
|
+
updatedAt: now,
|
|
188
|
+
...opts.ttlSeconds !== void 0 ? { ttlSeconds: opts.ttlSeconds } : {},
|
|
189
|
+
...opts.expiresAt !== void 0 ? { expiresAt: opts.expiresAt } : {}
|
|
190
|
+
};
|
|
191
|
+
await this.sql.query(
|
|
192
|
+
`INSERT INTO atlasflow_streams
|
|
193
|
+
(path, content_type, closed, current_offset, last_seq, created_at, updated_at, ttl_seconds, expires_at, producers, closed_by)
|
|
194
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
|
195
|
+
[
|
|
196
|
+
record.path,
|
|
197
|
+
record.contentType,
|
|
198
|
+
record.closed,
|
|
199
|
+
record.currentOffset,
|
|
200
|
+
record.lastSeq ?? null,
|
|
201
|
+
record.createdAt,
|
|
202
|
+
record.updatedAt,
|
|
203
|
+
record.ttlSeconds ?? null,
|
|
204
|
+
record.expiresAt ?? null,
|
|
205
|
+
json(record.producers),
|
|
206
|
+
json(record.closedBy)
|
|
207
|
+
]
|
|
208
|
+
);
|
|
209
|
+
for (const message of inheritedMessages) {
|
|
210
|
+
await this.sql.query("INSERT INTO atlasflow_stream_messages (path, idx, stream_offset, data_base64, created_at) VALUES ($1,$2,$3,$4,$5)", [
|
|
211
|
+
path,
|
|
212
|
+
message.index,
|
|
213
|
+
message.offset,
|
|
214
|
+
message.dataBase64,
|
|
215
|
+
message.createdAt
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
218
|
+
if ((opts.initialData?.length ?? 0) > 0) {
|
|
219
|
+
const normalized = normalizeStreamBody(opts.initialData, contentType, true);
|
|
220
|
+
if (normalized.length > 0) {
|
|
221
|
+
const message = makeStreamMessage(parseStreamMessageIndex(record.currentOffset) + 1, normalized);
|
|
222
|
+
record = { ...record, currentOffset: message.offset, updatedAt: message.createdAt };
|
|
223
|
+
await this.sql.query("INSERT INTO atlasflow_stream_messages (path, idx, stream_offset, data_base64, created_at) VALUES ($1,$2,$3,$4,$5)", [
|
|
224
|
+
path,
|
|
225
|
+
message.index,
|
|
226
|
+
message.offset,
|
|
227
|
+
message.dataBase64,
|
|
228
|
+
message.createdAt
|
|
229
|
+
]);
|
|
230
|
+
await this.sql.query("UPDATE atlasflow_streams SET current_offset=$2, updated_at=$3 WHERE path=$1", [path, record.currentOffset, record.updatedAt]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { record, created: true };
|
|
234
|
+
}
|
|
235
|
+
async get(path) {
|
|
236
|
+
const { rows } = await this.sql.query("SELECT * FROM atlasflow_streams WHERE path = $1", [path]);
|
|
237
|
+
return rows[0] ? rowToStream(rows[0]) : null;
|
|
238
|
+
}
|
|
239
|
+
async delete(path) {
|
|
240
|
+
const existing = await this.get(path);
|
|
241
|
+
if (!existing) return false;
|
|
242
|
+
await this.sql.query("DELETE FROM atlasflow_stream_messages WHERE path = $1", [path]);
|
|
243
|
+
await this.sql.query("DELETE FROM atlasflow_streams WHERE path = $1", [path]);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
async append(path, data, opts) {
|
|
247
|
+
const existing = await this.get(path);
|
|
248
|
+
if (!existing) return null;
|
|
249
|
+
assertStreamContentType(existing.contentType, opts.contentType);
|
|
250
|
+
if (existing.closed) throw new Error("stream is closed");
|
|
251
|
+
assertStreamSeq(existing, opts.seq);
|
|
252
|
+
const normalized = normalizeStreamBody(data, existing.contentType, false);
|
|
253
|
+
const message = makeStreamMessage(parseStreamMessageIndex(existing.currentOffset) + 1, normalized);
|
|
254
|
+
const record = {
|
|
255
|
+
...existing,
|
|
256
|
+
currentOffset: message.offset,
|
|
257
|
+
...opts.seq !== void 0 ? { lastSeq: opts.seq } : {},
|
|
258
|
+
closed: opts.close === true,
|
|
259
|
+
updatedAt: message.createdAt
|
|
260
|
+
};
|
|
261
|
+
await this.sql.query("INSERT INTO atlasflow_stream_messages (path, idx, stream_offset, data_base64, created_at) VALUES ($1,$2,$3,$4,$5)", [
|
|
262
|
+
path,
|
|
263
|
+
message.index,
|
|
264
|
+
message.offset,
|
|
265
|
+
message.dataBase64,
|
|
266
|
+
message.createdAt
|
|
267
|
+
]);
|
|
268
|
+
await this.sql.query("UPDATE atlasflow_streams SET current_offset=$2, closed=$3, last_seq=$4, updated_at=$5 WHERE path=$1", [
|
|
269
|
+
path,
|
|
270
|
+
record.currentOffset,
|
|
271
|
+
record.closed,
|
|
272
|
+
record.lastSeq ?? null,
|
|
273
|
+
record.updatedAt
|
|
274
|
+
]);
|
|
275
|
+
return { record, message };
|
|
276
|
+
}
|
|
277
|
+
async appendWithProducer(path, data, opts) {
|
|
278
|
+
const existing = await this.get(path);
|
|
279
|
+
if (!existing) return null;
|
|
280
|
+
if (existing.closed) return closedStreamProducerAppend(existing, opts.producer);
|
|
281
|
+
assertStreamContentType(existing.contentType, opts.contentType);
|
|
282
|
+
const producerResult = validateStreamProducer(existing, opts.producer);
|
|
283
|
+
if (producerResult.status !== "accepted") return { record: existing, message: null, producerResult };
|
|
284
|
+
assertStreamSeq(existing, opts.seq);
|
|
285
|
+
const normalized = normalizeStreamBody(data, existing.contentType, false);
|
|
286
|
+
const message = makeStreamMessage(parseStreamMessageIndex(existing.currentOffset) + 1, normalized);
|
|
287
|
+
const record = {
|
|
288
|
+
...existing,
|
|
289
|
+
currentOffset: message.offset,
|
|
290
|
+
...opts.seq !== void 0 ? { lastSeq: opts.seq } : {},
|
|
291
|
+
closed: opts.close === true,
|
|
292
|
+
updatedAt: message.createdAt
|
|
293
|
+
};
|
|
294
|
+
commitStreamProducer(record, producerResult);
|
|
295
|
+
if (opts.close) record.closedBy = { ...opts.producer };
|
|
296
|
+
await this.sql.query("INSERT INTO atlasflow_stream_messages (path, idx, stream_offset, data_base64, created_at) VALUES ($1,$2,$3,$4,$5)", [
|
|
297
|
+
path,
|
|
298
|
+
message.index,
|
|
299
|
+
message.offset,
|
|
300
|
+
message.dataBase64,
|
|
301
|
+
message.createdAt
|
|
302
|
+
]);
|
|
303
|
+
await this.sql.query(
|
|
304
|
+
"UPDATE atlasflow_streams SET current_offset=$2, closed=$3, last_seq=$4, updated_at=$5, producers=$6, closed_by=$7 WHERE path=$1",
|
|
305
|
+
[path, record.currentOffset, record.closed, record.lastSeq ?? null, record.updatedAt, json(record.producers), json(record.closedBy)]
|
|
306
|
+
);
|
|
307
|
+
return { record, message, producerResult, streamClosed: opts.close === true };
|
|
308
|
+
}
|
|
309
|
+
async close(path) {
|
|
310
|
+
const existing = await this.get(path);
|
|
311
|
+
if (!existing) return null;
|
|
312
|
+
const record = { ...existing, closed: true, updatedAt: Date.now() };
|
|
313
|
+
await this.sql.query("UPDATE atlasflow_streams SET closed=true, updated_at=$2 WHERE path=$1", [path, record.updatedAt]);
|
|
314
|
+
return record;
|
|
315
|
+
}
|
|
316
|
+
async closeWithProducer(path, producer) {
|
|
317
|
+
const existing = await this.get(path);
|
|
318
|
+
if (!existing) return null;
|
|
319
|
+
if (existing.closed) {
|
|
320
|
+
if (producerMatchesClosedBy(existing, producer)) {
|
|
321
|
+
return { record: existing, alreadyClosed: true, producerResult: { status: "duplicate", lastSeq: producer.seq } };
|
|
322
|
+
}
|
|
323
|
+
return { record: existing, alreadyClosed: true, producerResult: { status: "stream_closed" } };
|
|
324
|
+
}
|
|
325
|
+
const producerResult = validateStreamProducer(existing, producer);
|
|
326
|
+
if (producerResult.status !== "accepted") return { record: existing, alreadyClosed: false, producerResult };
|
|
327
|
+
const record = { ...existing, closed: true, closedBy: { ...producer }, updatedAt: Date.now() };
|
|
328
|
+
commitStreamProducer(record, producerResult);
|
|
329
|
+
await this.sql.query(
|
|
330
|
+
"UPDATE atlasflow_streams SET closed=true, updated_at=$2, producers=$3, closed_by=$4 WHERE path=$1",
|
|
331
|
+
[path, record.updatedAt, json(record.producers), json(record.closedBy)]
|
|
332
|
+
);
|
|
333
|
+
return { record, alreadyClosed: false, producerResult };
|
|
334
|
+
}
|
|
335
|
+
async messages(path) {
|
|
336
|
+
const { rows } = await this.sql.query("SELECT * FROM atlasflow_stream_messages WHERE path = $1 ORDER BY idx ASC", [path]);
|
|
337
|
+
return rows.map(rowToStreamMessage);
|
|
338
|
+
}
|
|
339
|
+
async list() {
|
|
340
|
+
const { rows } = await this.sql.query("SELECT path FROM atlasflow_streams ORDER BY path ASC");
|
|
341
|
+
return rows.map((row) => row.path);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
var PgSubscriptionStore = class extends DurableStreamSubscriptionStoreBase {
|
|
345
|
+
constructor(sql, streams, webhookOptions) {
|
|
346
|
+
super(streams, void 0, webhookOptions);
|
|
347
|
+
this.sql = sql;
|
|
348
|
+
}
|
|
349
|
+
sql;
|
|
350
|
+
async load(id) {
|
|
351
|
+
const { rows } = await this.sql.query("SELECT data FROM atlasflow_stream_subscriptions WHERE id = $1", [id]);
|
|
352
|
+
return rows[0] ? JSON.parse(rows[0].data) : null;
|
|
353
|
+
}
|
|
354
|
+
async save(record) {
|
|
355
|
+
await this.sql.query(
|
|
356
|
+
`INSERT INTO atlasflow_stream_subscriptions (id, data, updated_at)
|
|
357
|
+
VALUES ($1, $2, $3)
|
|
358
|
+
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
|
|
359
|
+
[record.id, JSON.stringify(record), Date.now()]
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
async remove(id) {
|
|
363
|
+
const existing = await this.load(id);
|
|
364
|
+
if (!existing) return false;
|
|
365
|
+
await this.sql.query("DELETE FROM atlasflow_stream_subscriptions WHERE id = $1", [id]);
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
async listRecords() {
|
|
369
|
+
const { rows } = await this.sql.query("SELECT data FROM atlasflow_stream_subscriptions ORDER BY id ASC");
|
|
370
|
+
return rows.map((row) => JSON.parse(row.data));
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
function json(value) {
|
|
374
|
+
return value === void 0 || value === null ? null : JSON.stringify(value);
|
|
375
|
+
}
|
|
376
|
+
function timestampOrNow(value) {
|
|
377
|
+
return typeof value === "number" && Number.isFinite(value) ? value : Date.now();
|
|
378
|
+
}
|
|
379
|
+
function nullableTimestamp(value) {
|
|
380
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
381
|
+
}
|
|
382
|
+
function rowToRun(row) {
|
|
383
|
+
return {
|
|
384
|
+
runId: row.run_id,
|
|
385
|
+
agent: row.agent,
|
|
386
|
+
instanceId: row.instance_id,
|
|
387
|
+
status: row.status,
|
|
388
|
+
result: parse(row.result),
|
|
389
|
+
error: parse(row.error),
|
|
390
|
+
startedAt: Number(row.started_at),
|
|
391
|
+
endedAt: row.ended_at == null ? void 0 : Number(row.ended_at),
|
|
392
|
+
message: row.message ?? void 0,
|
|
393
|
+
payload: parse(row.payload),
|
|
394
|
+
images: parse(row.images),
|
|
395
|
+
kind: row.kind ?? void 0,
|
|
396
|
+
state: parse(row.state),
|
|
397
|
+
leaseOwner: row.lease_owner ?? void 0,
|
|
398
|
+
leaseAcquiredAt: row.lease_acquired_at == null ? void 0 : Number(row.lease_acquired_at),
|
|
399
|
+
leaseExpiresAt: row.lease_expires_at == null ? void 0 : Number(row.lease_expires_at)
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function parse(value) {
|
|
403
|
+
return value == null ? void 0 : JSON.parse(value);
|
|
404
|
+
}
|
|
405
|
+
function rowToStream(row) {
|
|
406
|
+
return {
|
|
407
|
+
path: row.path,
|
|
408
|
+
contentType: row.content_type,
|
|
409
|
+
closed: Boolean(row.closed),
|
|
410
|
+
currentOffset: row.current_offset,
|
|
411
|
+
lastSeq: row.last_seq ?? void 0,
|
|
412
|
+
createdAt: Number(row.created_at),
|
|
413
|
+
updatedAt: Number(row.updated_at),
|
|
414
|
+
ttlSeconds: row.ttl_seconds == null ? void 0 : Number(row.ttl_seconds),
|
|
415
|
+
expiresAt: row.expires_at ?? void 0,
|
|
416
|
+
producers: parse(row.producers),
|
|
417
|
+
closedBy: parse(row.closed_by)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function rowToStreamMessage(row) {
|
|
421
|
+
return {
|
|
422
|
+
index: Number(row.idx),
|
|
423
|
+
offset: row.stream_offset,
|
|
424
|
+
dataBase64: row.data_base64,
|
|
425
|
+
createdAt: Number(row.created_at)
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function normalizeStreamContentType(value) {
|
|
429
|
+
const normalized = value?.split(";")[0]?.trim().toLowerCase();
|
|
430
|
+
return normalized && /^[\w.+-]+\/[\w.+-]+$/.test(normalized) ? normalized : "application/octet-stream";
|
|
431
|
+
}
|
|
432
|
+
function isJsonStreamContentType(contentType) {
|
|
433
|
+
return normalizeStreamContentType(contentType) === "application/json";
|
|
434
|
+
}
|
|
435
|
+
function normalizeStreamBody(data, contentType, initialCreate) {
|
|
436
|
+
if (!isJsonStreamContentType(contentType)) return data;
|
|
437
|
+
let parsed;
|
|
438
|
+
try {
|
|
439
|
+
parsed = JSON.parse(new TextDecoder().decode(data));
|
|
440
|
+
} catch {
|
|
441
|
+
throw new Error("Invalid JSON");
|
|
442
|
+
}
|
|
443
|
+
if (Array.isArray(parsed)) {
|
|
444
|
+
if (parsed.length === 0) {
|
|
445
|
+
if (initialCreate) return new Uint8Array(0);
|
|
446
|
+
throw new Error("Empty arrays are not allowed");
|
|
447
|
+
}
|
|
448
|
+
return new TextEncoder().encode(JSON.stringify(parsed));
|
|
449
|
+
}
|
|
450
|
+
return new TextEncoder().encode(JSON.stringify([parsed]));
|
|
451
|
+
}
|
|
452
|
+
function assertStreamContentType(expected, actual) {
|
|
453
|
+
if (normalizeStreamContentType(expected) !== normalizeStreamContentType(actual)) throw new Error("Content-type mismatch");
|
|
454
|
+
}
|
|
455
|
+
function assertStreamSeq(record, seq) {
|
|
456
|
+
if (seq === void 0) return;
|
|
457
|
+
if (seq.length === 0) throw new Error("Invalid Stream-Seq");
|
|
458
|
+
if (record.lastSeq !== void 0 && seq <= record.lastSeq) throw new Error("Stream sequence conflict");
|
|
459
|
+
}
|
|
460
|
+
function resolveForkContentType(source, requested) {
|
|
461
|
+
const sourceContentType = normalizeStreamContentType(source.contentType);
|
|
462
|
+
if (requested === void 0) return sourceContentType;
|
|
463
|
+
const requestedContentType = normalizeStreamContentType(requested);
|
|
464
|
+
if (requestedContentType !== sourceContentType) throw new Error("Content-type mismatch");
|
|
465
|
+
return requestedContentType;
|
|
466
|
+
}
|
|
467
|
+
function resolveForkOffset(source, requested) {
|
|
468
|
+
const offset = requested ?? source.currentOffset;
|
|
469
|
+
const index = parseStreamMessageIndex(offset);
|
|
470
|
+
const tail = parseStreamMessageIndex(source.currentOffset);
|
|
471
|
+
if (index > tail) throw new Error("Fork offset is beyond source tail");
|
|
472
|
+
return offset;
|
|
473
|
+
}
|
|
474
|
+
var PRODUCER_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
475
|
+
function validateStreamProducer(record, producer) {
|
|
476
|
+
cleanupExpiredProducers(record);
|
|
477
|
+
const state = record.producers?.[producer.producerId];
|
|
478
|
+
const now = Date.now();
|
|
479
|
+
if (!state) {
|
|
480
|
+
if (producer.seq !== 0) return { status: "sequence_gap", expectedSeq: 0, receivedSeq: producer.seq };
|
|
481
|
+
return {
|
|
482
|
+
status: "accepted",
|
|
483
|
+
isNew: true,
|
|
484
|
+
producerId: producer.producerId,
|
|
485
|
+
proposedState: { epoch: producer.epoch, lastSeq: 0, lastUpdated: now }
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (producer.epoch < state.epoch) return { status: "stale_epoch", currentEpoch: state.epoch };
|
|
489
|
+
if (producer.epoch > state.epoch) {
|
|
490
|
+
if (producer.seq !== 0) return { status: "invalid_epoch_seq" };
|
|
491
|
+
return {
|
|
492
|
+
status: "accepted",
|
|
493
|
+
isNew: true,
|
|
494
|
+
producerId: producer.producerId,
|
|
495
|
+
proposedState: { epoch: producer.epoch, lastSeq: 0, lastUpdated: now }
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (producer.seq <= state.lastSeq) return { status: "duplicate", lastSeq: state.lastSeq };
|
|
499
|
+
if (producer.seq === state.lastSeq + 1) {
|
|
500
|
+
return {
|
|
501
|
+
status: "accepted",
|
|
502
|
+
isNew: false,
|
|
503
|
+
producerId: producer.producerId,
|
|
504
|
+
proposedState: { epoch: producer.epoch, lastSeq: producer.seq, lastUpdated: now }
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return { status: "sequence_gap", expectedSeq: state.lastSeq + 1, receivedSeq: producer.seq };
|
|
508
|
+
}
|
|
509
|
+
function commitStreamProducer(record, result) {
|
|
510
|
+
if (result.status !== "accepted") return;
|
|
511
|
+
record.producers = { ...record.producers ?? {}, [result.producerId]: { ...result.proposedState } };
|
|
512
|
+
}
|
|
513
|
+
function cleanupExpiredProducers(record) {
|
|
514
|
+
if (!record.producers) return;
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
const next = {};
|
|
517
|
+
for (const [id, state] of Object.entries(record.producers)) {
|
|
518
|
+
if (now - state.lastUpdated <= PRODUCER_STATE_TTL_MS) next[id] = state;
|
|
519
|
+
}
|
|
520
|
+
record.producers = Object.keys(next).length ? next : void 0;
|
|
521
|
+
}
|
|
522
|
+
function producerMatchesClosedBy(record, producer) {
|
|
523
|
+
return record.closedBy?.producerId === producer.producerId && record.closedBy.epoch === producer.epoch && record.closedBy.seq === producer.seq;
|
|
524
|
+
}
|
|
525
|
+
function closedStreamProducerAppend(record, producer) {
|
|
526
|
+
if (producerMatchesClosedBy(record, producer)) {
|
|
527
|
+
return {
|
|
528
|
+
record,
|
|
529
|
+
message: null,
|
|
530
|
+
streamClosed: true,
|
|
531
|
+
producerResult: { status: "duplicate", lastSeq: producer.seq }
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
return { record, message: null, streamClosed: true, producerResult: { status: "stream_closed" } };
|
|
535
|
+
}
|
|
536
|
+
function makeStreamMessage(index, data) {
|
|
537
|
+
return { index, offset: formatStreamOffset(index), dataBase64: bytesToBase64(data), createdAt: Date.now() };
|
|
538
|
+
}
|
|
539
|
+
function formatStreamOffset(index) {
|
|
540
|
+
if (index < 0) return "-1";
|
|
541
|
+
return `0000000000000000_${String(index).padStart(16, "0")}`;
|
|
542
|
+
}
|
|
543
|
+
function parseStreamMessageIndex(offset) {
|
|
544
|
+
if (offset === "-1") return -1;
|
|
545
|
+
const match = /^\d+_(\d+)$/.exec(offset);
|
|
546
|
+
if (!match) throw new Error(`Invalid stream offset: ${offset}`);
|
|
547
|
+
return Number(match[1]);
|
|
548
|
+
}
|
|
549
|
+
function bytesToBase64(bytes) {
|
|
550
|
+
let binary = "";
|
|
551
|
+
for (let i = 0; i < bytes.length; i += 32768) {
|
|
552
|
+
binary += String.fromCharCode(...bytes.slice(i, i + 32768));
|
|
553
|
+
}
|
|
554
|
+
return btoa(binary);
|
|
555
|
+
}
|
|
556
|
+
var MIGRATE_SQL = [
|
|
557
|
+
`CREATE TABLE IF NOT EXISTS atlasflow_sessions (key TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at BIGINT NOT NULL)`,
|
|
558
|
+
`CREATE TABLE IF NOT EXISTS atlasflow_runs (
|
|
559
|
+
run_id TEXT PRIMARY KEY, agent TEXT NOT NULL, instance_id TEXT NOT NULL, status TEXT NOT NULL,
|
|
560
|
+
result TEXT, error TEXT, started_at BIGINT NOT NULL, ended_at BIGINT, message TEXT, payload TEXT)`,
|
|
561
|
+
// v2: multimodal agent input replay for durable/background runs.
|
|
562
|
+
`ALTER TABLE atlasflow_runs ADD COLUMN IF NOT EXISTS images TEXT`,
|
|
563
|
+
`CREATE TABLE IF NOT EXISTS atlasflow_run_events (run_id TEXT NOT NULL, idx INTEGER NOT NULL, event TEXT NOT NULL, PRIMARY KEY (run_id, idx))`,
|
|
564
|
+
// v3: workflow runs (gated processes) — additive, safe on existing tables
|
|
565
|
+
`ALTER TABLE atlasflow_runs ADD COLUMN IF NOT EXISTS kind TEXT`,
|
|
566
|
+
`ALTER TABLE atlasflow_runs ADD COLUMN IF NOT EXISTS state TEXT`,
|
|
567
|
+
// v4: execution leases prevent duplicate recovery / duplicate side effects.
|
|
568
|
+
`ALTER TABLE atlasflow_runs ADD COLUMN IF NOT EXISTS lease_owner TEXT`,
|
|
569
|
+
`ALTER TABLE atlasflow_runs ADD COLUMN IF NOT EXISTS lease_acquired_at BIGINT`,
|
|
570
|
+
`ALTER TABLE atlasflow_runs ADD COLUMN IF NOT EXISTS lease_expires_at BIGINT`,
|
|
571
|
+
`CREATE TABLE IF NOT EXISTS atlasflow_streams (
|
|
572
|
+
path TEXT PRIMARY KEY,
|
|
573
|
+
content_type TEXT NOT NULL,
|
|
574
|
+
closed BOOLEAN NOT NULL,
|
|
575
|
+
current_offset TEXT NOT NULL,
|
|
576
|
+
last_seq TEXT,
|
|
577
|
+
created_at BIGINT NOT NULL,
|
|
578
|
+
updated_at BIGINT NOT NULL,
|
|
579
|
+
ttl_seconds BIGINT,
|
|
580
|
+
expires_at TEXT,
|
|
581
|
+
producers TEXT,
|
|
582
|
+
closed_by TEXT
|
|
583
|
+
)`,
|
|
584
|
+
`ALTER TABLE atlasflow_streams ADD COLUMN IF NOT EXISTS producers TEXT`,
|
|
585
|
+
`ALTER TABLE atlasflow_streams ADD COLUMN IF NOT EXISTS closed_by TEXT`,
|
|
586
|
+
`ALTER TABLE atlasflow_streams ADD COLUMN IF NOT EXISTS last_seq TEXT`,
|
|
587
|
+
`CREATE TABLE IF NOT EXISTS atlasflow_stream_messages (
|
|
588
|
+
path TEXT NOT NULL,
|
|
589
|
+
idx INTEGER NOT NULL,
|
|
590
|
+
stream_offset TEXT NOT NULL,
|
|
591
|
+
data_base64 TEXT NOT NULL,
|
|
592
|
+
created_at BIGINT NOT NULL,
|
|
593
|
+
PRIMARY KEY (path, idx)
|
|
594
|
+
)`,
|
|
595
|
+
`CREATE TABLE IF NOT EXISTS atlasflow_stream_subscriptions (
|
|
596
|
+
id TEXT PRIMARY KEY,
|
|
597
|
+
data TEXT NOT NULL,
|
|
598
|
+
updated_at BIGINT NOT NULL
|
|
599
|
+
)`
|
|
600
|
+
];
|
|
601
|
+
function postgresAdapter(client, options = {}) {
|
|
602
|
+
const streams = new PgStreamStore(client);
|
|
603
|
+
return {
|
|
604
|
+
sessions: new PgSessionStore(client),
|
|
605
|
+
runs: new PgRunStore(client),
|
|
606
|
+
streams,
|
|
607
|
+
subscriptions: new PgSubscriptionStore(client, streams, options.webhooks),
|
|
608
|
+
async migrate() {
|
|
609
|
+
for (const stmt of MIGRATE_SQL) await client.query(stmt);
|
|
610
|
+
},
|
|
611
|
+
async close() {
|
|
612
|
+
await client.end?.();
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
function postgres(connectionString, options = {}) {
|
|
617
|
+
const sql = postgresDriver(connectionString);
|
|
618
|
+
const client = {
|
|
619
|
+
async query(text, params = []) {
|
|
620
|
+
const rows = await sql.unsafe(text, params);
|
|
621
|
+
return { rows };
|
|
622
|
+
},
|
|
623
|
+
end: () => sql.end()
|
|
624
|
+
};
|
|
625
|
+
return postgresAdapter(client, options);
|
|
626
|
+
}
|
|
627
|
+
export {
|
|
628
|
+
postgres,
|
|
629
|
+
postgresAdapter
|
|
630
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cybernetyx1/atlasflow-postgres",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PostgreSQL persistence adapter for AtlasFlow sessions, runs, journals, and streams.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"author": "Cybernetyx",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Cybernetyx/atlasflow.git",
|
|
11
|
+
"directory": "packages/postgres"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"postgres": "^3.4.5",
|
|
24
|
+
"@cybernetyx1/atlasflow-runtime": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@electric-sql/pglite": "^0.2.17",
|
|
28
|
+
"@types/node": "^22.10.0",
|
|
29
|
+
"tsup": "^8.3.5",
|
|
30
|
+
"typescript": "^5.7.2",
|
|
31
|
+
"valibot": "^1.0.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
40
|
+
}
|
|
41
|
+
}
|