@poppykit/server 0.1.1 → 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/API.md +4 -0
- package/CHANGELOG.md +4 -0
- package/README.md +3 -1
- package/STORAGE.md +68 -0
- package/dist/adapter-contract.d.ts +8 -0
- package/dist/adapter-contract.js +30 -0
- package/dist/postgres-runtime.d.ts +30 -0
- package/dist/postgres-runtime.js +172 -0
- package/dist/postgres.d.ts +31 -0
- package/dist/postgres.js +303 -0
- package/migrations/postgres/0001_poppy_server.sql +16 -0
- package/package.json +18 -3
package/API.md
CHANGED
|
@@ -19,3 +19,7 @@ and do not need the optional reference issuer. Your framework owns HTTPS routing
|
|
|
19
19
|
trusted public URLs, ingress limits, request cancellation and private-error handling.
|
|
20
20
|
|
|
21
21
|
Run the [quickstart](examples/quickstart.mjs) without a listener or external service.
|
|
22
|
+
|
|
23
|
+
The optional Postgres entry point ships its adapters, `createPostgresPool`,
|
|
24
|
+
`postgresMigration`, `migratePostgres` and schema-version export; see [storage](STORAGE.md).
|
|
25
|
+
TypeScript Postgres consumers also install `@types/pg@8.23.1` as a development dependency.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (release candidate)
|
|
4
|
+
|
|
5
|
+
Add bundled Postgres company stores/coordinator with explicit migrations, atomic host transaction composition, savepoints and pool lifecycle.
|
|
6
|
+
|
|
3
7
|
## 0.1.1
|
|
4
8
|
|
|
5
9
|
Documentation refresh: registry installation, runnable quickstarts, focused guides
|
package/README.md
CHANGED
|
@@ -6,12 +6,14 @@ supplies accounts, consent UI, agent, business tools and HTTPS routing. [MIT](LI
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
+
Version 0.2.0 is a release candidate until its publishing workflow completes.
|
|
10
|
+
|
|
9
11
|
Requires Node **24.12.0+** and ESM. CI tests Node 24.12.0 on Linux, macOS and Windows;
|
|
10
12
|
later majors are not separately certified. SQLite is optional/experimental and
|
|
11
13
|
requires local filesystem locking.
|
|
12
14
|
|
|
13
15
|
```sh
|
|
14
|
-
npm install @poppykit/server@0.
|
|
16
|
+
npm install @poppykit/server@0.2.0
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
## Configure a service
|
package/STORAGE.md
CHANGED
|
@@ -110,3 +110,71 @@ state. Default payload retention is indefinite; operators should retain at least
|
|
|
110
110
|
30 days and explicitly choose any shorter policy. See [DELIVERY.md](DELIVERY.md)
|
|
111
111
|
for distinct lifetimes and client recovery behavior. No automatic destructive
|
|
112
112
|
reset or history deletion was added.
|
|
113
|
+
|
|
114
|
+
## Postgres
|
|
115
|
+
|
|
116
|
+
`@poppykit/server/postgres` ships `PostgresCompanyDatabase`, explicit migrations,
|
|
117
|
+
and `createPostgresPool` in this package. Install optional peer `pg@8.23.1` when
|
|
118
|
+
using that subpath. Core/reference-auth imports load neither database driver.
|
|
119
|
+
Supported baseline: Node 24.12+, PostgreSQL 17, including Supabase transaction pooling.
|
|
120
|
+
No application, Google login or Supabase Auth dependency is required.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { createPostgresPool, migratePostgres,
|
|
124
|
+
PostgresCompanyDatabase } from '@poppykit/server/postgres';
|
|
125
|
+
const pool = createPostgresPool({ connectionString: process.env.DATABASE_URL });
|
|
126
|
+
// Explicit operator/deployment migration; do this before handling requests.
|
|
127
|
+
await migratePostgres(pool, 'poppy_server');
|
|
128
|
+
const database = new PostgresCompanyDatabase(pool);
|
|
129
|
+
const issuerStore = database.issuer('https://company.example/issuer');
|
|
130
|
+
const conversationStore = database.conversations('https://company.example/issuer');
|
|
131
|
+
// Reference issuer: store: issuerStore, coordinator: database.
|
|
132
|
+
// Conversation service: store: conversationStore, policy: issuer.authority.policy.
|
|
133
|
+
await database.close(); // after in-flight requests end; leaves caller pool open
|
|
134
|
+
await pool.end();
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Use one database object for issuer/coordinator/conversations. `withTransaction`
|
|
138
|
+
passes a generic query connection to host DB work inside that same transaction:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
await issuer.authority.policy.withCurrent(principal, ['records:write'], () =>
|
|
142
|
+
database.withTransaction(async connection => {
|
|
143
|
+
await connection.query('UPDATE application.records SET value=$1 WHERE owner=$2',
|
|
144
|
+
[validatedValue, trustedAccountId]);
|
|
145
|
+
}));
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The policy holds the issuer authority fence through commit; revocation waits or
|
|
149
|
+
wins before mutation. The host must still validate ownership, payload digests,
|
|
150
|
+
current business revisions and its own idempotency receipts. Issuer namespace
|
|
151
|
+
metadata and per-conversation binding/message locks use transaction-scoped advisory
|
|
152
|
+
locks, including absent keys; no process mutex or session lock substitutes for them.
|
|
153
|
+
Nested mutations use savepoints. Await nested mutations sequentially: overlapping
|
|
154
|
+
siblings reject explicitly, while recursion is supported. Escaped query handles
|
|
155
|
+
reject after their scope ends. External model/network/KMS calls belong outside this
|
|
156
|
+
boundary. Fences do not make arbitrary external effects exactly-once.
|
|
157
|
+
|
|
158
|
+
Pool defaults are max 2, acquisition 3s/idle 10s, verified TLS; configure a trusted
|
|
159
|
+
CA through `ssl`, never insecure TLS URL overrides. Explicit `ssl:false` is for a
|
|
160
|
+
trusted local test server. Caller-created pools remain their owner's responsibility.
|
|
161
|
+
Transactions/statements are bounded to 5s, lock waits 2s, with unnamed parameterized
|
|
162
|
+
queries and one checkout from BEGIN to COMMIT/ROLLBACK. No LISTEN, named prepares
|
|
163
|
+
or session state is required. `close` never closes a caller-supplied pool; callers
|
|
164
|
+
of `createPostgresPool` own and must end that pool.
|
|
165
|
+
|
|
166
|
+
Immutable SQL ships at `migrations/postgres/0001_poppy_server.sql`.
|
|
167
|
+
`postgresMigration('poppy_server')` returns reviewable SQL and
|
|
168
|
+
`migratePostgres(pool,'poppy_server')` is the explicit atomic apply/version check.
|
|
169
|
+
No constructor/request-time DDL or automatic SQLite import/reset exists. Unknown or
|
|
170
|
+
missing versions fail closed. Version 1 does not certify arbitrary manual table
|
|
171
|
+
edits: inspect migration history and reconcile drift before applying new versions.
|
|
172
|
+
Applied migrations are immutable, corrections require new versioned files. Back up,
|
|
173
|
+
review and rehearse live changes; roll application code back only to compatible
|
|
174
|
+
schemas, or restore a reviewed backup for destructive reversal. No populated-schema
|
|
175
|
+
drop/downgrade is automated. The disposable test runner is development tooling,
|
|
176
|
+
never a production migration command.
|
|
177
|
+
|
|
178
|
+
`runIssuerAdapterContract` joins the existing reusable conversation contract in
|
|
179
|
+
`@poppykit/server/adapter-contract`. Run both against two fresh shared handles;
|
|
180
|
+
process death, lock ordering and DB durability still require backend-specific tests.
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { ConversationOptions } from './conversation/service.js';
|
|
2
|
+
import type { IssuerStore } from './reference-auth/store.js';
|
|
3
|
+
export type IssuerAdapterFixture = () => Promise<{
|
|
4
|
+
store: IssuerStore;
|
|
5
|
+
second: IssuerStore;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}>;
|
|
8
|
+
/** A fresh shared namespace, including absent-row serialization; no vendor-specific fixture is required. */
|
|
9
|
+
export declare function runIssuerAdapterContract(fixture: IssuerAdapterFixture): void;
|
|
2
10
|
type Store = ConversationOptions['store'] & {
|
|
3
11
|
inspect(): Promise<import('./conversation/service.js').ConversationStoreState>;
|
|
4
12
|
adminTransaction<T>(change: (draft: import('./conversation/service.js').ConversationStoreState) => T): Promise<T>;
|
package/dist/adapter-contract.js
CHANGED
|
@@ -5,6 +5,24 @@ import test from 'node:test';
|
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { AuthorityError } from './auth/authority.js';
|
|
7
7
|
import { createConversationService, resetConversationState } from './conversation/service.js';
|
|
8
|
+
/** A fresh shared namespace, including absent-row serialization; no vendor-specific fixture is required. */
|
|
9
|
+
export function runIssuerAdapterContract(fixture) {
|
|
10
|
+
test('issuer adapter contract: replay updates serialize, snapshots detach and failed callbacks roll back', async () => {
|
|
11
|
+
const f = await fixture(), selection = { kind: 'browser', sessionId: 'absent', replayKey: 'counter' };
|
|
12
|
+
try {
|
|
13
|
+
await Promise.all(Array.from({ length: 10 }, (_, i) => (i % 2 ? f.store : f.second).transaction(selection, state => { state.replay.counter = (state.replay.counter ?? 0) + 1; })));
|
|
14
|
+
const state = await f.store.read(selection);
|
|
15
|
+
assert.equal(state.replay.counter, 10);
|
|
16
|
+
state.replay.counter = 999;
|
|
17
|
+
await assert.rejects(f.store.transaction(selection, draft => { draft.replay.counter = 0; throw new Error('rollback'); }), /rollback/);
|
|
18
|
+
await assert.rejects(f.store.transaction(selection, async (draft) => { draft.replay.counter = 0; }));
|
|
19
|
+
assert.equal((await f.second.read(selection)).replay.counter, 10);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await f.close();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
8
26
|
const endpoint = 'https://contract.example.test/poppy/conversations';
|
|
9
27
|
const base = { issuer: 'https://auth.contract.test', clientId: 'https://agent.contract.test/client', sessionId: 's1', userId: 'u_1', scopes: [], tokenExpiresAt: 9e12, sessionVersion: 1 };
|
|
10
28
|
const principals = {
|
|
@@ -51,6 +69,18 @@ export function runConversationAdapterContract(fixture) {
|
|
|
51
69
|
await opened.close();
|
|
52
70
|
}
|
|
53
71
|
}
|
|
72
|
+
test('adapter contract: concurrent conflicting output retries publish one immutable result', () => withFixture(async ({ store, second }) => {
|
|
73
|
+
const a = serviceOn(store, clock), b = serviceOn(second, clock);
|
|
74
|
+
const id = (await call(a, '', 'signedOut', { message })).body.conversation_id;
|
|
75
|
+
const work = (await a.claim());
|
|
76
|
+
const results = await Promise.allSettled([a.reply(work, 'same-call', reply), b.reply(work, 'same-call', { ...reply, text: 'conflicting result' })]);
|
|
77
|
+
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
|
78
|
+
const failed = results.find(result => result.status === 'rejected');
|
|
79
|
+
assert.ok(failed && failed.status === 'rejected');
|
|
80
|
+
assert.equal(failed.reason.code, 'output_conflict');
|
|
81
|
+
const events = (await call(a, `/${id}/events`, 'signedOut')).body.events;
|
|
82
|
+
assert.equal(events.filter(event => event.message?.id === reply.id).length, 1);
|
|
83
|
+
}));
|
|
54
84
|
test('adapter contract: reads and results are detached; failed, non-JSON and async changes roll back', () => withFixture(async ({ store, second }) => {
|
|
55
85
|
const snapshot = await store.inspect();
|
|
56
86
|
snapshot.bindings.leaked = {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Pool, PoolConfig, QueryResult, QueryResultRow } from 'pg';
|
|
2
|
+
export type PostgresSchema = 'poppy_client' | 'poppy_provider' | 'poppy_server';
|
|
3
|
+
export type PostgresConnection = {
|
|
4
|
+
query<R extends QueryResultRow = QueryResultRow>(sql: string, values?: unknown[]): Promise<QueryResult<R>>;
|
|
5
|
+
};
|
|
6
|
+
export declare function json(value: unknown): string;
|
|
7
|
+
export declare function synchronous<T>(value: T): T;
|
|
8
|
+
/** Remote defaults verify TLS; plaintext must be explicitly selected for a trusted local test server. */
|
|
9
|
+
export declare function createPostgresPool(config: PoolConfig): Pool;
|
|
10
|
+
export declare const postgresSchemaVersion = 1;
|
|
11
|
+
export declare function postgresMigration(schema: PostgresSchema): Promise<string>;
|
|
12
|
+
/** Explicit administration only. Constructors never execute DDL. */
|
|
13
|
+
export declare function migratePostgres(pool: Pool, schema: PostgresSchema): Promise<void>;
|
|
14
|
+
export declare class PostgresRuntime {
|
|
15
|
+
readonly pool: Pool;
|
|
16
|
+
readonly schema: PostgresSchema;
|
|
17
|
+
private readonly ownsPool;
|
|
18
|
+
private readonly context;
|
|
19
|
+
private closed;
|
|
20
|
+
readonly metrics: {
|
|
21
|
+
rowsRead: number;
|
|
22
|
+
rowsWritten: number;
|
|
23
|
+
};
|
|
24
|
+
constructor(pool: Pool, schema: PostgresSchema, ownsPool?: boolean);
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
run<T>(action: () => Promise<T>): Promise<T>;
|
|
27
|
+
connection(): PostgresConnection;
|
|
28
|
+
query<R extends QueryResultRow = QueryResultRow>(sql: string, values?: unknown[]): Promise<R[]>;
|
|
29
|
+
lock(key: string): Promise<void>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import pg from 'pg';
|
|
4
|
+
export function json(value) {
|
|
5
|
+
return JSON.stringify(value, (_key, item) => {
|
|
6
|
+
if (item === undefined || typeof item === 'function' || typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item)))
|
|
7
|
+
throw new TypeError('non-JSON record');
|
|
8
|
+
return item;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export function synchronous(value) {
|
|
12
|
+
if (value && typeof value.then === 'function')
|
|
13
|
+
throw new TypeError('transaction callback must be synchronous');
|
|
14
|
+
return structuredClone(value);
|
|
15
|
+
}
|
|
16
|
+
/** Remote defaults verify TLS; plaintext must be explicitly selected for a trusted local test server. */
|
|
17
|
+
export function createPostgresPool(config) {
|
|
18
|
+
if (config.ssl && typeof config.ssl === 'object' && config.ssl.rejectUnauthorized === false)
|
|
19
|
+
throw new TypeError('Postgres TLS verification is required');
|
|
20
|
+
if (config.connectionString && [...new URL(config.connectionString).searchParams.keys()].some(key => key.toLowerCase().startsWith('ssl') || key === 'uselibpqcompat'))
|
|
21
|
+
throw new TypeError('configure verified TLS through ssl, not connection-string overrides');
|
|
22
|
+
return new pg.Pool({ max: 2, connectionTimeoutMillis: 3000, idleTimeoutMillis: 10000, ...config, ssl: config.ssl ?? { rejectUnauthorized: true } });
|
|
23
|
+
}
|
|
24
|
+
export const postgresSchemaVersion = 1;
|
|
25
|
+
function schemaName(schema) {
|
|
26
|
+
if (!['poppy_client', 'poppy_provider', 'poppy_server'].includes(schema))
|
|
27
|
+
throw new TypeError('unsupported Postgres schema');
|
|
28
|
+
return schema;
|
|
29
|
+
}
|
|
30
|
+
export async function postgresMigration(schema) {
|
|
31
|
+
schemaName(schema);
|
|
32
|
+
return readFile(new URL(`../migrations/postgres/0001_${schema}.sql`, import.meta.url), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
/** Explicit administration only. Constructors never execute DDL. */
|
|
35
|
+
export async function migratePostgres(pool, schema) {
|
|
36
|
+
schemaName(schema);
|
|
37
|
+
const client = await pool.connect();
|
|
38
|
+
try {
|
|
39
|
+
await client.query('BEGIN');
|
|
40
|
+
await client.query("SET LOCAL statement_timeout='5s'");
|
|
41
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [`poppykit:migrate:${schema}`]);
|
|
42
|
+
const exists = (await client.query('SELECT to_regclass($1) AS name', [`${schema}.schema_version`])).rows[0].name;
|
|
43
|
+
if (exists) {
|
|
44
|
+
const rows = (await client.query(`SELECT version FROM ${schema}.schema_version`)).rows;
|
|
45
|
+
if (rows.length !== 1 || rows[0].version !== postgresSchemaVersion)
|
|
46
|
+
throw new Error('unsupported Postgres schema version');
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
await client.query(await postgresMigration(schema));
|
|
50
|
+
await client.query('COMMIT');
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
await client.query('ROLLBACK');
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
client.release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function scopeActive(context) {
|
|
61
|
+
for (let scope = context; scope; scope = scope.parent)
|
|
62
|
+
if (!scope.active || !scope.state.active)
|
|
63
|
+
return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
export class PostgresRuntime {
|
|
67
|
+
pool;
|
|
68
|
+
schema;
|
|
69
|
+
ownsPool;
|
|
70
|
+
context = new AsyncLocalStorage();
|
|
71
|
+
closed = false;
|
|
72
|
+
metrics = { rowsRead: 0, rowsWritten: 0 };
|
|
73
|
+
constructor(pool, schema, ownsPool = false) {
|
|
74
|
+
this.pool = pool;
|
|
75
|
+
this.schema = schema;
|
|
76
|
+
this.ownsPool = ownsPool;
|
|
77
|
+
}
|
|
78
|
+
async close() { this.closed = true; if (this.ownsPool)
|
|
79
|
+
await this.pool.end(); }
|
|
80
|
+
async run(action) {
|
|
81
|
+
if (this.closed)
|
|
82
|
+
throw new Error('Postgres adapter is closed');
|
|
83
|
+
const parent = this.context.getStore();
|
|
84
|
+
if (parent) {
|
|
85
|
+
if (!scopeActive(parent))
|
|
86
|
+
throw new Error('expired Postgres transaction');
|
|
87
|
+
if (parent.childActive)
|
|
88
|
+
throw new Error('concurrent nested Postgres transactions are unsupported; await each mutation');
|
|
89
|
+
parent.childActive = true;
|
|
90
|
+
const savepoint = `nested_${++parent.state.serial}`;
|
|
91
|
+
const child = { ...parent, parent, active: true, childActive: false, locks: new Set(parent.locks) };
|
|
92
|
+
let started = false;
|
|
93
|
+
try {
|
|
94
|
+
await parent.client.query(`SAVEPOINT ${savepoint}`);
|
|
95
|
+
started = true;
|
|
96
|
+
const result = await this.context.run(child, action);
|
|
97
|
+
if (!scopeActive(child))
|
|
98
|
+
throw new Error('expired Postgres transaction');
|
|
99
|
+
if (child.childActive)
|
|
100
|
+
throw new Error('unawaited nested Postgres transaction');
|
|
101
|
+
child.active = false;
|
|
102
|
+
await parent.client.query(`RELEASE SAVEPOINT ${savepoint}`);
|
|
103
|
+
parent.locks = child.locks;
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
child.active = false;
|
|
108
|
+
if (started && scopeActive(parent)) {
|
|
109
|
+
await parent.client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
110
|
+
await parent.client.query(`RELEASE SAVEPOINT ${savepoint}`);
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
child.active = false;
|
|
116
|
+
parent.childActive = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const client = await this.pool.connect(), context = { client, active: true, locks: new Set(), state: { serial: 0, active: true }, childActive: false };
|
|
120
|
+
try {
|
|
121
|
+
await client.query('BEGIN');
|
|
122
|
+
await client.query("SET LOCAL statement_timeout='5s'");
|
|
123
|
+
await client.query("SET LOCAL lock_timeout='2s'");
|
|
124
|
+
await client.query("SET LOCAL idle_in_transaction_session_timeout='5s'");
|
|
125
|
+
await client.query("SET LOCAL transaction_timeout='5s'");
|
|
126
|
+
const versions = (await client.query(`SELECT version FROM ${this.schema}.schema_version`)).rows;
|
|
127
|
+
if (versions.length !== 1 || versions[0].version !== postgresSchemaVersion)
|
|
128
|
+
throw new Error('unsupported Postgres schema version');
|
|
129
|
+
const result = await this.context.run(context, action);
|
|
130
|
+
if (context.childActive)
|
|
131
|
+
throw new Error('unawaited nested Postgres transaction');
|
|
132
|
+
await client.query('COMMIT');
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
context.state.active = false;
|
|
137
|
+
await client.query('ROLLBACK').catch(() => { });
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
context.active = false;
|
|
142
|
+
context.state.active = false;
|
|
143
|
+
client.release();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
connection() {
|
|
147
|
+
const context = this.context.getStore();
|
|
148
|
+
if (!context || !scopeActive(context))
|
|
149
|
+
throw new Error('no active Postgres transaction');
|
|
150
|
+
return { query: async (sql, values) => {
|
|
151
|
+
if (!scopeActive(context) || this.context.getStore() !== context)
|
|
152
|
+
throw new Error('expired Postgres transaction');
|
|
153
|
+
if (context.childActive)
|
|
154
|
+
throw new Error('concurrent Postgres transaction use is unsupported; await each mutation');
|
|
155
|
+
return context.client.query(sql, values);
|
|
156
|
+
} };
|
|
157
|
+
}
|
|
158
|
+
async query(sql, values = []) {
|
|
159
|
+
const result = await this.connection().query(sql, values);
|
|
160
|
+
this.metrics.rowsRead += result.rows.length;
|
|
161
|
+
return result.rows;
|
|
162
|
+
}
|
|
163
|
+
async lock(key) {
|
|
164
|
+
const context = this.context.getStore();
|
|
165
|
+
if (!context || !scopeActive(context))
|
|
166
|
+
throw new Error('no active Postgres transaction');
|
|
167
|
+
if (!context.locks.has(key)) {
|
|
168
|
+
await this.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [`${this.schema}:${key}`]);
|
|
169
|
+
context.locks.add(key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import type { ConversationStore } from './conversation/store.js';
|
|
3
|
+
import type { IssuerStore } from './reference-auth/store.js';
|
|
4
|
+
import type { PostgresConnection } from './postgres-runtime.js';
|
|
5
|
+
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
6
|
+
export type { PostgresConnection } from './postgres-runtime.js';
|
|
7
|
+
/** Share this coordinator between the issuer, conversation service and host business mutations. */
|
|
8
|
+
export declare class PostgresCompanyDatabase {
|
|
9
|
+
private readonly db;
|
|
10
|
+
constructor(pool: Pool);
|
|
11
|
+
get metrics(): {
|
|
12
|
+
rowsRead: number;
|
|
13
|
+
rowsWritten: number;
|
|
14
|
+
};
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
run<T>(action: () => Promise<T>): Promise<T>;
|
|
17
|
+
withTransaction<T>(action: (connection: PostgresConnection) => Promise<T>): Promise<T>;
|
|
18
|
+
private atomic;
|
|
19
|
+
private sqlAll;
|
|
20
|
+
private sqlGet;
|
|
21
|
+
private sqlRun;
|
|
22
|
+
private rows;
|
|
23
|
+
private one;
|
|
24
|
+
private put;
|
|
25
|
+
private save;
|
|
26
|
+
private meta;
|
|
27
|
+
conversations(namespace: string): ConversationStore;
|
|
28
|
+
issuer(namespace: string): IssuerStore;
|
|
29
|
+
/** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
|
|
30
|
+
cleanup(namespace: string, now: number, limit?: number): Promise<number>;
|
|
31
|
+
}
|
package/dist/postgres.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { initialConversationState } from './conversation/service.js';
|
|
2
|
+
import { initialIssuerState } from './reference-auth/index.js';
|
|
3
|
+
import { clientRevisionKey, accountRevisionKey } from './reference-auth/store.js';
|
|
4
|
+
import { PostgresRuntime, json } from './postgres-runtime.js';
|
|
5
|
+
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
6
|
+
/** Share this coordinator between the issuer, conversation service and host business mutations. */
|
|
7
|
+
export class PostgresCompanyDatabase {
|
|
8
|
+
db;
|
|
9
|
+
constructor(pool) { this.db = new PostgresRuntime(pool, 'poppy_server'); }
|
|
10
|
+
get metrics() { return this.db.metrics; }
|
|
11
|
+
close() { return this.db.close(); }
|
|
12
|
+
run(action) { return this.db.run(action); }
|
|
13
|
+
withTransaction(action) { return this.run(() => action(this.db.connection())); }
|
|
14
|
+
atomic(action) { return this.db.run(action); }
|
|
15
|
+
sqlAll(sql, values = []) { return this.db.query(sql, values); }
|
|
16
|
+
async sqlGet(sql, values = []) { return (await this.sqlAll(sql, values))[0]; }
|
|
17
|
+
async sqlRun(sql, values = []) { const result = await this.db.connection().query(sql, values); return { changes: result.rowCount ?? 0 }; }
|
|
18
|
+
async rows(ns, kind, where = '', params = []) { return this.db.query(`SELECT id,body FROM poppy_server.records WHERE ns=$1 AND kind=$2 ${where}`, [ns, kind, ...params]); }
|
|
19
|
+
async one(ns, kind, id) { const row = (await this.rows(ns, kind, 'AND id=$3', [id]))[0]; return row ? JSON.parse(row.body) : undefined; }
|
|
20
|
+
async put(ns, kind, id, value) { await this.sqlRun('INSERT INTO poppy_server.records(ns,kind,id,body) VALUES($1,$2,$3,$4) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body', [ns, kind, id, json(value)]); this.metrics.rowsWritten++; }
|
|
21
|
+
async save(ns, kind, before, after) {
|
|
22
|
+
for (const id of Object.keys(before))
|
|
23
|
+
if (!Object.hasOwn(after, id)) {
|
|
24
|
+
await this.sqlRun('DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3', [ns, kind, id]);
|
|
25
|
+
this.metrics.rowsWritten++;
|
|
26
|
+
}
|
|
27
|
+
for (const [id, value] of Object.entries(after))
|
|
28
|
+
if (!Object.hasOwn(before, id) || json(before[id]) !== json(value))
|
|
29
|
+
await this.put(ns, kind, id, value);
|
|
30
|
+
}
|
|
31
|
+
async meta(ns, kind, initial) {
|
|
32
|
+
const found = await this.one(ns, kind, 'meta');
|
|
33
|
+
if (found)
|
|
34
|
+
return found;
|
|
35
|
+
await this.db.lock(JSON.stringify([ns, kind, 'meta']));
|
|
36
|
+
const raced = await this.one(ns, kind, 'meta');
|
|
37
|
+
if (raced)
|
|
38
|
+
return raced;
|
|
39
|
+
const value = initial();
|
|
40
|
+
await this.put(ns, kind, 'meta', value);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
conversations(namespace) {
|
|
44
|
+
const load = async (selection) => {
|
|
45
|
+
const state = { ...await this.meta(namespace, 'conversation_meta', () => { const { version, generation } = initialConversationState(); return { version, generation }; }), conversations: {}, bindings: {}, messages: {}, work: [] };
|
|
46
|
+
if (selection.messageKey)
|
|
47
|
+
await this.db.lock(JSON.stringify([namespace, 'message', selection.messageKey]));
|
|
48
|
+
let id = selection.conversationId;
|
|
49
|
+
if (selection.messageKey) {
|
|
50
|
+
const message = await this.one(namespace, 'messages', selection.messageKey);
|
|
51
|
+
if (message) {
|
|
52
|
+
state.messages[selection.messageKey] = message;
|
|
53
|
+
id = message.conversationId;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (selection.workId)
|
|
57
|
+
id = (await this.one(namespace, 'work', selection.workId))?.conversationId;
|
|
58
|
+
if (selection.claimAt !== undefined) {
|
|
59
|
+
const row = await this.sqlGet(`SELECT w.body FROM poppy_server.records w JOIN poppy_server.records c ON c.ns=w.ns AND c.kind='conversations' AND c.id=(w.body::jsonb#>>'{conversationId}')
|
|
60
|
+
WHERE w.ns=$1 AND w.kind='work' AND (w.body::jsonb#>>'{phase}') IN ('pending','claimed','held')
|
|
61
|
+
AND (c.body::jsonb#>>'{childId}') IS NULL AND (
|
|
62
|
+
((c.body::jsonb#>>'{responder}')='human' AND coalesce(((c.body::jsonb#>>'{humanUntil}')::double precision),0)<=$2) OR
|
|
63
|
+
((c.body::jsonb#>>'{responder}')!='human' AND (c.body::jsonb#>>'{status}')!='queued' AND (w.body::jsonb#>>'{phase}') IN ('pending','claimed')
|
|
64
|
+
AND ((w.body::jsonb#>>'{phase}')='pending' OR coalesce(((w.body::jsonb#>>'{leaseUntil}')::double precision),0)<=$3)
|
|
65
|
+
AND NOT EXISTS (SELECT 1 FROM poppy_server.records earlier WHERE earlier.ns=w.ns AND earlier.kind='work' AND earlier.ordinal<w.ordinal
|
|
66
|
+
AND (earlier.body::jsonb#>>'{conversationId}')=(w.body::jsonb#>>'{conversationId}') AND (earlier.body::jsonb#>>'{phase}') IN ('pending','claimed'))))
|
|
67
|
+
ORDER BY w.ordinal LIMIT 1`, [namespace, selection.claimAt, selection.claimAt]);
|
|
68
|
+
if (row) {
|
|
69
|
+
this.metrics.rowsRead++;
|
|
70
|
+
id = JSON.parse(row.body).conversationId;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let target = id ? await this.one(namespace, 'conversations', id) : undefined;
|
|
74
|
+
if (target) {
|
|
75
|
+
await this.db.lock(JSON.stringify([namespace, 'binding', target.bindingId]));
|
|
76
|
+
target = await this.one(namespace, 'conversations', target.id);
|
|
77
|
+
}
|
|
78
|
+
if (!target)
|
|
79
|
+
return state;
|
|
80
|
+
if (selection.cursorCheck !== undefined) {
|
|
81
|
+
const found = await this.sqlGet("SELECT seq FROM poppy_server.event_cursors WHERE ns=$1 AND conversation=$2 AND id=$3", [namespace, target.id, selection.cursorCheck]);
|
|
82
|
+
if (!found)
|
|
83
|
+
state.cursorStatus = 'invalid';
|
|
84
|
+
else if (!await this.sqlGet("SELECT 1 FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq=$3", [namespace, target.id, Number(found.seq)]))
|
|
85
|
+
state.cursorStatus = 'expired';
|
|
86
|
+
}
|
|
87
|
+
for (const row of await this.rows(namespace, 'conversations', selection.events ? 'AND id=$3' : "AND (body::jsonb->>'bindingId')=$3", [selection.events ? target.id : target.bindingId])) {
|
|
88
|
+
const conversation = JSON.parse(row.body);
|
|
89
|
+
let from = -1;
|
|
90
|
+
if (selection.events?.cursor !== undefined) {
|
|
91
|
+
const found = await this.sqlGet("SELECT seq FROM poppy_server.event_cursors WHERE ns=$1 AND conversation=$2 AND id=$3", [namespace, row.id, selection.events.cursor]);
|
|
92
|
+
if (!found)
|
|
93
|
+
conversation.eventWindow = { invalid: true, hasMore: false };
|
|
94
|
+
else {
|
|
95
|
+
from = Number(found.seq);
|
|
96
|
+
if (!await this.sqlGet("SELECT 1 FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq=$3", [namespace, row.id, from]))
|
|
97
|
+
conversation.eventWindow = { expired: true, hasMore: false };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const events = [];
|
|
101
|
+
let hasMore = false;
|
|
102
|
+
if (selection.events) {
|
|
103
|
+
let bytes = 0;
|
|
104
|
+
for (const event of await this.sqlAll("SELECT body FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq>$3 ORDER BY seq LIMIT $4", [namespace, row.id, from, selection.events.limit + 1])) {
|
|
105
|
+
this.metrics.rowsRead++;
|
|
106
|
+
const size = Buffer.byteLength(event.body) + 1;
|
|
107
|
+
if (events.length >= selection.events.limit || bytes + size > (selection.events.maxBytes ?? 1048576)) {
|
|
108
|
+
hasMore = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
events.push(event);
|
|
112
|
+
bytes += size;
|
|
113
|
+
}
|
|
114
|
+
conversation.eventWindow = { ...conversation.eventWindow, hasMore };
|
|
115
|
+
}
|
|
116
|
+
else if (!selection.append)
|
|
117
|
+
events.push(...await this.sqlAll("SELECT body FROM poppy_server.events WHERE ns=$1 AND conversation=$2 ORDER BY seq", [namespace, row.id]));
|
|
118
|
+
if (!selection.events)
|
|
119
|
+
this.metrics.rowsRead += events.length;
|
|
120
|
+
if (selection.append && !conversation.lastUserMessage) {
|
|
121
|
+
const last = await this.sqlGet("SELECT body FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND (body::jsonb#>>'{type}')='message' AND (body::jsonb#>>'{message,role}')='user' ORDER BY seq DESC LIMIT 1", [namespace, row.id]);
|
|
122
|
+
if (last) {
|
|
123
|
+
this.metrics.rowsRead++;
|
|
124
|
+
conversation.lastUserMessage = JSON.parse(last.body).message;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
conversation.events = events.map(event => JSON.parse(event.body));
|
|
128
|
+
state.conversations[row.id] = conversation;
|
|
129
|
+
if (!selection.events)
|
|
130
|
+
state.work.push(...(await this.rows(namespace, 'work', "AND (body::jsonb#>>'{conversationId}')=$3 ORDER BY ordinal", [row.id])).map(work => JSON.parse(work.body)));
|
|
131
|
+
}
|
|
132
|
+
const binding = await this.one(namespace, 'bindings', target.bindingId);
|
|
133
|
+
if (binding)
|
|
134
|
+
state.bindings[target.bindingId] = binding;
|
|
135
|
+
return state;
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
pruneEvents: (conversationId, before, limit = 100) => {
|
|
139
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || !Number.isFinite(before))
|
|
140
|
+
throw new TypeError('retention limit');
|
|
141
|
+
return this.run(async () => {
|
|
142
|
+
const target = await this.one(namespace, 'conversations', conversationId);
|
|
143
|
+
if (target)
|
|
144
|
+
await this.db.lock(JSON.stringify([namespace, 'binding', target.bindingId]));
|
|
145
|
+
const active = await this.sqlGet("SELECT 1 FROM poppy_server.records WHERE ns=$1 AND kind='work' AND (body::jsonb#>>'{conversationId}')=$2 AND (body::jsonb#>>'{phase}') IN ('held','pending','claimed') LIMIT 1", [namespace, conversationId]);
|
|
146
|
+
if (active)
|
|
147
|
+
return 0;
|
|
148
|
+
const removed = await this.sqlRun("DELETE FROM poppy_server.events WHERE ns=$1 AND conversation=$2 AND seq IN (SELECT seq FROM poppy_server.events WHERE ns=$3 AND conversation=$4 AND (body::jsonb#>>'{created_at}')<$5 ORDER BY seq LIMIT $6)", [namespace, conversationId, namespace, conversationId, new Date(before).toISOString(), limit]);
|
|
149
|
+
return Number(removed.changes);
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
read: selection => this.run(async () => await load(selection)),
|
|
153
|
+
transaction: (selection, change) => this.run(async () => this.atomic(async () => {
|
|
154
|
+
const state = await load(selection), before = structuredClone(state);
|
|
155
|
+
const result = change(state);
|
|
156
|
+
if (result && typeof result.then === 'function')
|
|
157
|
+
throw new TypeError('async conversation transaction');
|
|
158
|
+
if (selection.events) {
|
|
159
|
+
if (json(state) !== json(before))
|
|
160
|
+
throw new TypeError('event windows are read-only');
|
|
161
|
+
return structuredClone(result);
|
|
162
|
+
}
|
|
163
|
+
if (state.version !== before.version || state.generation !== before.generation || Object.keys(before.conversations).some(id => !Object.hasOwn(state.conversations, id)))
|
|
164
|
+
throw new TypeError('use an explicit offline reset');
|
|
165
|
+
for (const [id, conversation] of Object.entries(state.conversations)) {
|
|
166
|
+
const oldEvents = before.conversations[id]?.events ?? [];
|
|
167
|
+
if (conversation.events.length < oldEvents.length || oldEvents.some((event, i) => json(event) !== json(conversation.events[i])))
|
|
168
|
+
throw new TypeError('events must be append-only');
|
|
169
|
+
const last = Number((await this.sqlGet("SELECT coalesce(max(seq),-1) AS seq FROM poppy_server.event_cursors WHERE ns=$1 AND conversation=$2", [namespace, id])).seq);
|
|
170
|
+
for (let i = oldEvents.length; i < conversation.events.length; i++) {
|
|
171
|
+
const seq = last + i - oldEvents.length + 1, event = conversation.events[i];
|
|
172
|
+
await this.sqlRun("INSERT INTO poppy_server.events VALUES ($1,$2,$3,$4)", [namespace, id, seq, json(event)]);
|
|
173
|
+
await this.sqlRun("INSERT INTO poppy_server.event_cursors VALUES ($1,$2,$3,$4)", [namespace, id, event.id, seq]);
|
|
174
|
+
this.metrics.rowsWritten += 2;
|
|
175
|
+
}
|
|
176
|
+
const { events: _events, ...record } = conversation;
|
|
177
|
+
const { events: _oldEvents, ...old } = before.conversations[id] ?? {};
|
|
178
|
+
if (json(record) !== json(old))
|
|
179
|
+
await this.put(namespace, 'conversations', id, record);
|
|
180
|
+
}
|
|
181
|
+
await this.save(namespace, 'bindings', before.bindings, state.bindings);
|
|
182
|
+
await this.save(namespace, 'messages', before.messages, state.messages);
|
|
183
|
+
await this.save(namespace, 'work', Object.fromEntries(before.work.map(row => [row.id, row])), Object.fromEntries(state.work.map(row => [row.id, row])));
|
|
184
|
+
return structuredClone(result);
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
issuer(namespace) {
|
|
189
|
+
const kinds = ['sessions', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
|
|
190
|
+
const load = async (selection) => {
|
|
191
|
+
await this.db.lock(JSON.stringify([namespace, 'issuer']));
|
|
192
|
+
const state = { ...await this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
|
|
193
|
+
const get = async (kind, id) => {
|
|
194
|
+
if (!id)
|
|
195
|
+
return;
|
|
196
|
+
const value = await this.one(namespace, kind, id);
|
|
197
|
+
if (value !== undefined)
|
|
198
|
+
state[kind][id] = value;
|
|
199
|
+
};
|
|
200
|
+
const find = async (kind, where, ...params) => {
|
|
201
|
+
for (const row of await this.rows(namespace, kind, where, params))
|
|
202
|
+
state[kind][row.id] = JSON.parse(row.body);
|
|
203
|
+
};
|
|
204
|
+
if (selection.kind === 'access')
|
|
205
|
+
await get('accessTokens', selection.hash);
|
|
206
|
+
if (selection.kind === 'sign_in') {
|
|
207
|
+
for (const id of selection.ids)
|
|
208
|
+
await get('signIns', id);
|
|
209
|
+
await get('sessions', selection.sessionId);
|
|
210
|
+
await get('accessTokens', selection.accessHash);
|
|
211
|
+
for (const key of selection.replayKeys)
|
|
212
|
+
await get('replay', key);
|
|
213
|
+
const counts = (await this.sqlGet("SELECT count(*) AS n, sum(CASE WHEN (body::jsonb#>>'{clientId}')=$1 THEN 1 ELSE 0 END) AS client FROM poppy_server.records WHERE ns=$2 AND kind='signIns' AND (body::jsonb#>>'{status}') IN ('pending','approved','verifying') AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [selection.clientId ?? '', namespace, selection.at]));
|
|
214
|
+
state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(counts.n), pendingForClient: Number(counts.client ?? 0) };
|
|
215
|
+
}
|
|
216
|
+
if (selection.kind === 'principal') {
|
|
217
|
+
await get('sessions', selection.sessionId);
|
|
218
|
+
await get('grants', selection.grantId);
|
|
219
|
+
}
|
|
220
|
+
if (selection.kind === 'browser') {
|
|
221
|
+
await get('sessions', selection.sessionId);
|
|
222
|
+
await get('replay', selection.replayKey);
|
|
223
|
+
}
|
|
224
|
+
if (selection.kind === 'authorization') {
|
|
225
|
+
await get('authorizations', selection.id);
|
|
226
|
+
await get('signIns', selection.revocationKey);
|
|
227
|
+
}
|
|
228
|
+
if (selection.kind === 'token') {
|
|
229
|
+
await get('sessions', selection.sessionId);
|
|
230
|
+
await get('codes', selection.codeHash);
|
|
231
|
+
await get('refreshTokens', selection.refreshHash);
|
|
232
|
+
for (const key of selection.replayKeys)
|
|
233
|
+
await get('replay', key);
|
|
234
|
+
const counts = (await this.sqlGet("SELECT count(*) AS n, min(((body::jsonb#>>'{expiresAt}')::double precision)) AS expiry FROM poppy_server.records WHERE ns=$1 AND kind='sessions' AND (body::jsonb#>>'{clientId}')=$2 AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [namespace, selection.clientId, selection.at]));
|
|
235
|
+
state.limits = { sessions: Number(counts.n), earliestSessionExpiry: Number(counts.expiry ?? selection.at), pending: 0, pendingForClient: 0 };
|
|
236
|
+
}
|
|
237
|
+
if (selection.kind === 'revoke') {
|
|
238
|
+
await get('refreshTokens', selection.refreshHash);
|
|
239
|
+
await get('replay', selection.replayKey);
|
|
240
|
+
}
|
|
241
|
+
if (selection.kind === 'authorize') {
|
|
242
|
+
await get('signIns', clientRevisionKey(selection.clientId));
|
|
243
|
+
const pending = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND ((body::jsonb#>>'{expiresAt}')::double precision)>$2", [namespace, selection.at]));
|
|
244
|
+
const client = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND (body::jsonb#>>'{clientId}')=$2 AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [namespace, selection.clientId, selection.at]));
|
|
245
|
+
state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(pending.n), pendingForClient: Number(client.n) };
|
|
246
|
+
}
|
|
247
|
+
if (selection.kind === 'disconnect') {
|
|
248
|
+
for (const kind of ['grants', 'codes'])
|
|
249
|
+
await find(kind, "AND (body::jsonb->>'clientId')=$3 AND (body::jsonb->>'accountId')=$4", selection.clientId, selection.accountId);
|
|
250
|
+
await find('signIns', "AND (body::jsonb->>'clientId')=$3 AND (body::jsonb->>'accountId')=$4", selection.clientId, selection.accountId);
|
|
251
|
+
await get('signIns', clientRevisionKey(selection.clientId));
|
|
252
|
+
await get('signIns', accountRevisionKey(selection.clientId, selection.accountId));
|
|
253
|
+
}
|
|
254
|
+
for (const record of Object.values(state.accessTokens)) {
|
|
255
|
+
await get('sessions', record.sessionId);
|
|
256
|
+
if (record.grantId)
|
|
257
|
+
await get('grants', record.grantId);
|
|
258
|
+
}
|
|
259
|
+
for (const record of [...Object.values(state.sessions), ...Object.values(state.codes)])
|
|
260
|
+
if (record.grantId)
|
|
261
|
+
await get('grants', record.grantId);
|
|
262
|
+
for (const record of Object.values(state.codes))
|
|
263
|
+
await get('signIns', accountRevisionKey(record.clientId, record.accountId));
|
|
264
|
+
for (const id of Object.values(state.refreshTokens))
|
|
265
|
+
await get('grants', id);
|
|
266
|
+
for (const [id, grant] of Object.entries(state.grants)) {
|
|
267
|
+
await get('refreshTokens', grant.refreshHash);
|
|
268
|
+
await get('refreshTokens', grant.previousHash);
|
|
269
|
+
if (['token', 'revoke', 'disconnect'].includes(selection.kind))
|
|
270
|
+
await find('sessions', "AND (body::jsonb->>'grantId')=$3", id);
|
|
271
|
+
}
|
|
272
|
+
return state;
|
|
273
|
+
};
|
|
274
|
+
return {
|
|
275
|
+
read: selection => this.run(async () => await load(selection)),
|
|
276
|
+
transaction: (selection, change) => this.run(async () => this.atomic(async () => {
|
|
277
|
+
const state = await load(selection), before = structuredClone(state);
|
|
278
|
+
const result = change(state);
|
|
279
|
+
if (result && typeof result.then === 'function')
|
|
280
|
+
throw new TypeError('async issuer transaction');
|
|
281
|
+
for (const kind of kinds)
|
|
282
|
+
await this.save(namespace, kind, before[kind], state[kind]);
|
|
283
|
+
const { version, generation, nonces } = state;
|
|
284
|
+
if (json({ version, generation, nonces }) !== json({ version: before.version, generation: before.generation, nonces: before.nonces }))
|
|
285
|
+
await this.put(namespace, 'issuer_meta', 'meta', { version, generation, nonces });
|
|
286
|
+
return structuredClone(result);
|
|
287
|
+
})),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
|
|
291
|
+
cleanup(namespace, now, limit = 100) {
|
|
292
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
293
|
+
throw new TypeError('cleanup limit');
|
|
294
|
+
return this.run(async () => {
|
|
295
|
+
await this.db.lock(JSON.stringify([namespace, 'issuer']));
|
|
296
|
+
const rows = await this.sqlAll(`SELECT kind,id FROM poppy_server.records WHERE ns=$1 AND
|
|
297
|
+
((kind IN ('authorizations','codes','accessTokens') AND ((body::jsonb#>>'{expiresAt}')::double precision)<=$2) OR (kind='signIns' AND ((body::jsonb#>>'{expiresAt}')::double precision)+30000<$3) OR (kind='replay' AND (body::jsonb#>>'{}')::double precision<$4)) LIMIT $5`, [namespace, now, now, now, limit]);
|
|
298
|
+
for (const row of rows)
|
|
299
|
+
await this.sqlRun("DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3", [namespace, row.kind, row.id]);
|
|
300
|
+
return rows.length;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
CREATE SCHEMA poppy_server;
|
|
2
|
+
CREATE TABLE poppy_server.schema_version (version integer PRIMARY KEY CHECK(version > 0));
|
|
3
|
+
INSERT INTO poppy_server.schema_version VALUES (1);
|
|
4
|
+
CREATE TABLE poppy_server.records (ns text NOT NULL, kind text NOT NULL, id text NOT NULL, body text NOT NULL CHECK (body::jsonb IS NOT NULL), ordinal bigint GENERATED ALWAYS AS IDENTITY, PRIMARY KEY(ns,kind,id));
|
|
5
|
+
CREATE INDEX by_conversation ON poppy_server.records (ns,kind,(body::jsonb#>>'{conversationId}'));
|
|
6
|
+
CREATE INDEX by_binding ON poppy_server.records (ns,kind,(body::jsonb#>>'{bindingId}'));
|
|
7
|
+
CREATE INDEX by_client ON poppy_server.records (ns,kind,(body::jsonb#>>'{clientId}'));
|
|
8
|
+
CREATE INDEX by_account ON poppy_server.records (ns,kind,(body::jsonb#>>'{accountId}'));
|
|
9
|
+
CREATE INDEX by_grant ON poppy_server.records (ns,kind,(body::jsonb#>>'{grantId}'));
|
|
10
|
+
CREATE INDEX by_phase ON poppy_server.records (ns,kind,(body::jsonb#>>'{phase}'));
|
|
11
|
+
CREATE INDEX by_expiry ON poppy_server.records (ns,kind,((body::jsonb#>>'{expiresAt}')::double precision));
|
|
12
|
+
CREATE INDEX by_connection ON poppy_server.records (ns,kind,(body::jsonb#>>'{connectionId}'));
|
|
13
|
+
CREATE INDEX by_callback ON poppy_server.records (ns,kind,(body::jsonb#>>'{direct,stateHash}'));
|
|
14
|
+
CREATE TABLE poppy_server.events (ns text NOT NULL, conversation text NOT NULL, seq bigint NOT NULL, body text NOT NULL, PRIMARY KEY(ns,conversation,seq));
|
|
15
|
+
CREATE INDEX last_user_event ON poppy_server.events(ns,conversation,((body::jsonb)->>'type'),((body::jsonb)#>>'{message,role}'),seq);
|
|
16
|
+
CREATE TABLE poppy_server.event_cursors (ns text NOT NULL, conversation text NOT NULL, id text NOT NULL, seq bigint NOT NULL, PRIMARY KEY(ns,conversation,id), UNIQUE(ns,conversation,seq));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poppykit/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Personal Agent Protocol company SDK",
|
|
6
6
|
"repository": {
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "24.10.0",
|
|
27
|
-
"typescript": "5.9.3"
|
|
27
|
+
"typescript": "5.9.3",
|
|
28
|
+
"pg": "8.23.1",
|
|
29
|
+
"@types/pg": "8.23.1"
|
|
28
30
|
},
|
|
29
31
|
"files": [
|
|
30
32
|
"dist",
|
|
@@ -39,7 +41,8 @@
|
|
|
39
41
|
"CHANGELOG.md",
|
|
40
42
|
"RELEASING.md",
|
|
41
43
|
"examples",
|
|
42
|
-
"LICENSE"
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"migrations"
|
|
43
46
|
],
|
|
44
47
|
"exports": {
|
|
45
48
|
".": {
|
|
@@ -57,6 +60,10 @@
|
|
|
57
60
|
"./sqlite": {
|
|
58
61
|
"types": "./dist/sqlite.d.ts",
|
|
59
62
|
"import": "./dist/sqlite.js"
|
|
63
|
+
},
|
|
64
|
+
"./postgres": {
|
|
65
|
+
"types": "./dist/postgres.d.ts",
|
|
66
|
+
"import": "./dist/postgres.js"
|
|
60
67
|
}
|
|
61
68
|
},
|
|
62
69
|
"dependencies": {
|
|
@@ -65,5 +72,13 @@
|
|
|
65
72
|
"license": "MIT",
|
|
66
73
|
"publishConfig": {
|
|
67
74
|
"access": "public"
|
|
75
|
+
},
|
|
76
|
+
"peerDependencies": {
|
|
77
|
+
"pg": "^8.23.1"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"pg": {
|
|
81
|
+
"optional": true
|
|
82
|
+
}
|
|
68
83
|
}
|
|
69
84
|
}
|