@poppykit/client 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/API.md +4 -0
- package/CHANGELOG.md +15 -15
- package/CONTRIBUTING.md +30 -42
- package/DELIVERY.md +6 -25
- package/PROVIDER.md +25 -2
- package/README.md +26 -98
- package/RELEASING.md +91 -193
- package/SECURITY.md +3 -3
- package/STORAGE.md +65 -8
- package/dist/adapter-contract.d.ts +15 -0
- package/dist/adapter-contract.js +48 -0
- package/dist/postgres-runtime.d.ts +30 -0
- package/dist/postgres-runtime.js +172 -0
- package/dist/postgres.d.ts +39 -0
- package/dist/postgres.js +143 -0
- package/dist/provider-postgres.d.ts +15 -0
- package/dist/provider-postgres.js +96 -0
- package/dist/provider-sqlite.d.ts +2 -2
- package/dist/provider-sqlite.js +9 -6
- package/dist/provider-storage.d.ts +7 -0
- package/dist/provider-storage.js +61 -0
- package/dist/provider.d.ts +40 -10
- package/dist/provider.js +35 -20
- package/migrations/postgres/0001_poppy_client.sql +16 -0
- package/migrations/postgres/0001_poppy_provider.sql +14 -0
- package/package.json +26 -3
|
@@ -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,39 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import type { ClientAuthState } from './auth/client-auth.js';
|
|
3
|
+
import type { AuthSelection, AuthStore } from './auth/store.js';
|
|
4
|
+
import type { ConversationKey, ConversationStore } from './conversation-store.js';
|
|
5
|
+
import type { ConsumerCheckpoint, OutboundRecord } from './conversation.js';
|
|
6
|
+
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
7
|
+
/** Namespaced references only; credentials still belong in SecretStore. The pool remains caller-owned. */
|
|
8
|
+
export declare class PostgresAuthStore implements AuthStore {
|
|
9
|
+
readonly namespace: string;
|
|
10
|
+
private readonly db;
|
|
11
|
+
constructor(pool: Pool, namespace: string);
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
get metrics(): {
|
|
14
|
+
rowsRead: number;
|
|
15
|
+
rowsWritten: number;
|
|
16
|
+
};
|
|
17
|
+
private select;
|
|
18
|
+
read(selection: AuthSelection): Promise<ClientAuthState>;
|
|
19
|
+
transaction<T>(selection: AuthSelection, change: (draft: ClientAuthState) => T): Promise<T>;
|
|
20
|
+
private put;
|
|
21
|
+
pendingOperations(after: string | undefined, limit: number): Promise<string[]>;
|
|
22
|
+
cleanup(before: number, limit?: number): Promise<number>;
|
|
23
|
+
}
|
|
24
|
+
export declare class PostgresConversationStore implements ConversationStore {
|
|
25
|
+
readonly namespace: string;
|
|
26
|
+
private readonly db;
|
|
27
|
+
constructor(pool: Pool, namespace: string);
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
private outbound;
|
|
30
|
+
getOutbound(key: ConversationKey, id: string): Promise<OutboundRecord | undefined>;
|
|
31
|
+
prepareOutbound(key: ConversationKey, record: OutboundRecord): Promise<OutboundRecord>;
|
|
32
|
+
settleOutbound(key: ConversationKey, id: string, result: Pick<OutboundRecord, 'phase' | 'response' | 'error'>): Promise<void>;
|
|
33
|
+
removeOutbound(key: ConversationKey, id: string): Promise<void>;
|
|
34
|
+
private checkpointValues;
|
|
35
|
+
private checkpoint;
|
|
36
|
+
getCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<string | undefined>;
|
|
37
|
+
advanceCheckpoint(key: ConversationKey, row: ConsumerCheckpoint, from: readonly (string | undefined)[]): Promise<void>;
|
|
38
|
+
resetCheckpoint(key: ConversationKey, conversation: string, name: string): Promise<void>;
|
|
39
|
+
}
|
package/dist/postgres.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { initialClientAuthState } from './auth/client-auth.js';
|
|
2
|
+
import { PostgresRuntime, json, synchronous } from './postgres-runtime.js';
|
|
3
|
+
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
4
|
+
const tables = ['identities', 'connections', 'grants', 'operations', 'cleanup'];
|
|
5
|
+
/** Namespaced references only; credentials still belong in SecretStore. The pool remains caller-owned. */
|
|
6
|
+
export class PostgresAuthStore {
|
|
7
|
+
namespace;
|
|
8
|
+
db;
|
|
9
|
+
constructor(pool, namespace) {
|
|
10
|
+
this.namespace = namespace;
|
|
11
|
+
this.db = new PostgresRuntime(pool, 'poppy_client');
|
|
12
|
+
}
|
|
13
|
+
close() { return this.db.close(); }
|
|
14
|
+
get metrics() { return this.db.metrics; }
|
|
15
|
+
async select(selection) {
|
|
16
|
+
// Epoch/owner are shared by this auth namespace and must fence every connection mutation.
|
|
17
|
+
await this.db.lock(`${this.namespace}:auth`);
|
|
18
|
+
const get = async (table, id) => (await this.db.query('SELECT body FROM poppy_client.records WHERE ns=$1 AND kind=$2 AND id=$3', [this.namespace, table, id]))[0];
|
|
19
|
+
const meta = await get('auth_meta', 'meta');
|
|
20
|
+
const initial = initialClientAuthState();
|
|
21
|
+
if (!meta)
|
|
22
|
+
await this.put('auth_meta', 'meta', { version: initial.version, epoch: initial.epoch, owner: initial.owner });
|
|
23
|
+
const state = { ...(meta ? JSON.parse(meta.body) : { version: initial.version, epoch: initial.epoch, owner: initial.owner }), identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
|
|
24
|
+
const one = async (table, id) => { const row = await get(table, id); if (row)
|
|
25
|
+
state[table][id] = JSON.parse(row.body); };
|
|
26
|
+
const rows = async (table, where, values = []) => {
|
|
27
|
+
for (const row of await this.db.query(`SELECT id,body FROM poppy_client.records WHERE ns=$1 AND kind=$2 ${where}`, [this.namespace, table, ...values]))
|
|
28
|
+
state[table][row.id] = JSON.parse(row.body);
|
|
29
|
+
};
|
|
30
|
+
if ('issuer' in selection)
|
|
31
|
+
await one('identities', selection.issuer);
|
|
32
|
+
else {
|
|
33
|
+
if ('connectionId' in selection)
|
|
34
|
+
await one('connections', selection.connectionId);
|
|
35
|
+
else if ('operationId' in selection)
|
|
36
|
+
await one('operations', selection.operationId);
|
|
37
|
+
else if ('callbackHash' in selection)
|
|
38
|
+
await rows('operations', "AND body::jsonb#>>'{direct,stateHash}'=$3", [selection.callbackHash]);
|
|
39
|
+
else
|
|
40
|
+
await rows('connections', '');
|
|
41
|
+
for (const operation of Object.values(state.operations))
|
|
42
|
+
await one('connections', operation.connectionId);
|
|
43
|
+
for (const connection of Object.values(state.connections)) {
|
|
44
|
+
if (connection.grantId)
|
|
45
|
+
await one('grants', connection.grantId);
|
|
46
|
+
if (!('list' in selection)) {
|
|
47
|
+
await rows('operations', "AND body::jsonb->>'connectionId'=$3", [connection.id]);
|
|
48
|
+
await rows('cleanup', "AND body::jsonb->>'connectionId'=$3", [connection.id]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const operation of Object.values(state.operations))
|
|
52
|
+
if (operation.grantId)
|
|
53
|
+
await one('grants', operation.grantId);
|
|
54
|
+
}
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
read(selection) { return this.db.run(() => this.select(selection)); }
|
|
58
|
+
transaction(selection, change) {
|
|
59
|
+
return this.db.run(async () => {
|
|
60
|
+
const state = await this.select(selection), before = structuredClone(state), result = synchronous(change(state));
|
|
61
|
+
const { version, epoch, owner } = state;
|
|
62
|
+
await this.put('auth_meta', 'meta', { version, epoch, owner });
|
|
63
|
+
for (const table of tables) {
|
|
64
|
+
for (const id of Object.keys(before[table]))
|
|
65
|
+
if (!Object.hasOwn(state[table], id))
|
|
66
|
+
await this.db.query('DELETE FROM poppy_client.records WHERE ns=$1 AND kind=$2 AND id=$3', [this.namespace, table, id]);
|
|
67
|
+
for (const [id, value] of Object.entries(state[table]))
|
|
68
|
+
if (json(value) !== json(before[table][id] ?? null))
|
|
69
|
+
await this.put(table, id, value);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async put(kind, id, value) {
|
|
75
|
+
await this.db.query('INSERT INTO poppy_client.records(ns,kind,id,body) VALUES($1,$2,$3,$4) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body', [this.namespace, kind, id, json(value)]);
|
|
76
|
+
this.db.metrics.rowsWritten++;
|
|
77
|
+
}
|
|
78
|
+
async pendingOperations(after, limit) {
|
|
79
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
80
|
+
throw new TypeError('recovery limit');
|
|
81
|
+
return this.db.run(async () => (await this.db.query("SELECT id FROM poppy_client.records WHERE ns=$1 AND kind='operations' AND body::jsonb->>'phase'!='terminal' AND id>$2 ORDER BY id LIMIT $3", [this.namespace, after ?? '', limit])).map(row => row.id));
|
|
82
|
+
}
|
|
83
|
+
async cleanup(before, limit = 100) {
|
|
84
|
+
if (!Number.isFinite(before) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
85
|
+
throw new TypeError('cleanup limit');
|
|
86
|
+
return this.db.run(async () => { await this.db.lock(`${this.namespace}:auth`); return (await this.db.query("DELETE FROM poppy_client.records WHERE ns=$1 AND kind='operations' AND id IN (SELECT id FROM poppy_client.records WHERE ns=$1 AND kind='operations' AND body::jsonb->>'phase'='terminal' AND body::jsonb->>'status'!='uncertain' AND (body::jsonb->>'createdAt')::double precision<$2 ORDER BY (body::jsonb->>'createdAt')::double precision LIMIT $3) RETURNING id", [this.namespace, before, limit])).length; });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class PostgresConversationStore {
|
|
90
|
+
namespace;
|
|
91
|
+
db;
|
|
92
|
+
constructor(pool, namespace) {
|
|
93
|
+
this.namespace = namespace;
|
|
94
|
+
this.db = new PostgresRuntime(pool, 'poppy_client');
|
|
95
|
+
}
|
|
96
|
+
close() { return this.db.close(); }
|
|
97
|
+
async outbound(key, id) {
|
|
98
|
+
const row = (await this.db.query('SELECT body FROM poppy_client.outbound WHERE ns=$1 AND connection=$2 AND issuer=$3 AND id=$4', [this.namespace, key.connectionId, key.issuer, id]))[0];
|
|
99
|
+
return row ? JSON.parse(row.body) : undefined;
|
|
100
|
+
}
|
|
101
|
+
getOutbound(key, id) { return this.db.run(() => this.outbound(key, id)); }
|
|
102
|
+
async prepareOutbound(key, record) {
|
|
103
|
+
if (record.connectionId !== key.connectionId || record.issuer !== key.issuer || record.endpoint !== key.endpoint)
|
|
104
|
+
throw new TypeError('outbound binding');
|
|
105
|
+
return this.db.run(async () => {
|
|
106
|
+
await this.db.query('INSERT INTO poppy_client.outbound VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING', [this.namespace, key.connectionId, key.issuer, record.id, json(record)]);
|
|
107
|
+
return (await this.outbound(key, record.id));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
settleOutbound(key, id, result) {
|
|
111
|
+
return this.db.run(async () => {
|
|
112
|
+
await this.db.lock(JSON.stringify([this.namespace, 'outbound', key.connectionId, key.issuer, id]));
|
|
113
|
+
const record = await this.outbound(key, id);
|
|
114
|
+
if (!record || record.endpoint !== key.endpoint)
|
|
115
|
+
throw new TypeError('outbound binding');
|
|
116
|
+
if (record.phase === 'pending')
|
|
117
|
+
await this.db.query('UPDATE poppy_client.outbound SET body=$5 WHERE ns=$1 AND connection=$2 AND issuer=$3 AND id=$4', [this.namespace, key.connectionId, key.issuer, id, json({ ...record, ...result })]);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
removeOutbound(key, id) {
|
|
121
|
+
return this.db.run(async () => {
|
|
122
|
+
await this.db.lock(JSON.stringify([this.namespace, 'outbound', key.connectionId, key.issuer, id]));
|
|
123
|
+
const record = await this.outbound(key, id);
|
|
124
|
+
if (record && record.endpoint !== key.endpoint)
|
|
125
|
+
throw new TypeError('outbound binding');
|
|
126
|
+
await this.db.query('DELETE FROM poppy_client.outbound WHERE ns=$1 AND connection=$2 AND issuer=$3 AND id=$4', [this.namespace, key.connectionId, key.issuer, id]);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
checkpointValues(key, conversation, name) { return [this.namespace, key.connectionId, key.issuer, key.endpoint, conversation, name]; }
|
|
130
|
+
async checkpoint(key, conversation, name) { return (await this.db.query('SELECT cursor FROM poppy_client.checkpoints WHERE ns=$1 AND connection=$2 AND issuer=$3 AND endpoint=$4 AND conversation=$5 AND name=$6', this.checkpointValues(key, conversation, name)))[0]?.cursor; }
|
|
131
|
+
getCheckpoint(key, conversation, name) { return this.db.run(() => this.checkpoint(key, conversation, name)); }
|
|
132
|
+
async advanceCheckpoint(key, row, from) {
|
|
133
|
+
if (row.connectionId !== key.connectionId || row.issuer !== key.issuer)
|
|
134
|
+
throw new TypeError('checkpoint binding');
|
|
135
|
+
return this.db.run(async () => {
|
|
136
|
+
const values = this.checkpointValues(key, row.conversationId, row.name);
|
|
137
|
+
await this.db.lock(JSON.stringify(['checkpoint', ...values]));
|
|
138
|
+
if (from.includes(await this.checkpoint(key, row.conversationId, row.name)))
|
|
139
|
+
await this.db.query('INSERT INTO poppy_client.checkpoints VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(ns,connection,issuer,endpoint,conversation,name) DO UPDATE SET cursor=excluded.cursor', [...values, row.cursor]);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
resetCheckpoint(key, conversation, name) { return this.db.run(async () => { const values = this.checkpointValues(key, conversation, name); await this.db.lock(JSON.stringify(['checkpoint', ...values])); await this.db.query('DELETE FROM poppy_client.checkpoints WHERE ns=$1 AND connection=$2 AND issuer=$3 AND endpoint=$4 AND conversation=$5 AND name=$6', values); }); }
|
|
143
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import type { ProviderSelection, ProviderStore, ProviderTransaction } from './provider.js';
|
|
3
|
+
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
4
|
+
/** Targeted provider aggregates. Cryptography and human/session verification run outside its transactions. */
|
|
5
|
+
export declare class PostgresProviderStore implements ProviderStore {
|
|
6
|
+
readonly namespace: string;
|
|
7
|
+
private readonly db;
|
|
8
|
+
constructor(pool: Pool, namespace: string);
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
get metrics(): {
|
|
11
|
+
rowsRead: number;
|
|
12
|
+
rowsWritten: number;
|
|
13
|
+
};
|
|
14
|
+
transaction<T>(selection: ProviderSelection, action: (tx: ProviderTransaction) => T): Promise<T>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { providerMemoryTransaction, providerUserKey, scopedProviderTransaction } from './provider-storage.js';
|
|
2
|
+
import { PostgresRuntime, json, synchronous } from './postgres-runtime.js';
|
|
3
|
+
export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
|
|
4
|
+
/** Targeted provider aggregates. Cryptography and human/session verification run outside its transactions. */
|
|
5
|
+
export class PostgresProviderStore {
|
|
6
|
+
namespace;
|
|
7
|
+
db;
|
|
8
|
+
constructor(pool, namespace) {
|
|
9
|
+
this.namespace = namespace;
|
|
10
|
+
this.db = new PostgresRuntime(pool, 'poppy_provider');
|
|
11
|
+
}
|
|
12
|
+
close() { return this.db.close(); }
|
|
13
|
+
get metrics() { return this.db.metrics; }
|
|
14
|
+
transaction(selection, action) {
|
|
15
|
+
return this.db.run(async () => {
|
|
16
|
+
const rows = new Map();
|
|
17
|
+
const one = async (kind, id) => {
|
|
18
|
+
const row = (await this.db.query('SELECT body FROM poppy_provider.records WHERE ns=$1 AND kind=$2 AND id=$3', [this.namespace, kind, id]))[0];
|
|
19
|
+
const key = JSON.stringify([kind, id]);
|
|
20
|
+
rows.delete(key);
|
|
21
|
+
if (row)
|
|
22
|
+
rows.set(key, JSON.parse(row.body));
|
|
23
|
+
return row ? JSON.parse(row.body) : undefined;
|
|
24
|
+
};
|
|
25
|
+
const lock = (kind, id) => this.db.lock(JSON.stringify([this.namespace, kind, id]));
|
|
26
|
+
if (selection.kind === 'enroll') {
|
|
27
|
+
await lock('capacity', 'enrollments');
|
|
28
|
+
await one('capacity', 'enrollments');
|
|
29
|
+
await one('visitor', selection.subjectHash);
|
|
30
|
+
await one('enrollmentRequest', selection.requestHash);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const lookup = selection.kind === 'callback' ? await one('state', selection.stateHash) : undefined;
|
|
34
|
+
const enrollmentId = selection.kind === 'callback' ? lookup?.enrollmentId : selection.enrollmentId;
|
|
35
|
+
if (enrollmentId) {
|
|
36
|
+
await lock('enrollment', enrollmentId);
|
|
37
|
+
const enrollment = await one('enrollment', enrollmentId);
|
|
38
|
+
if ('issuer' in selection && enrollment) {
|
|
39
|
+
const key = providerUserKey(enrollment.visitor, selection.issuer);
|
|
40
|
+
await lock('user', key);
|
|
41
|
+
await one('user', key);
|
|
42
|
+
}
|
|
43
|
+
if (selection.kind === 'begin' || selection.kind === 'revoke') {
|
|
44
|
+
const operations = await this.db.query("SELECT id,body FROM poppy_provider.records WHERE ns=$1 AND kind='operation' AND split_part(id,':',1)=$2 ORDER BY id LIMIT 1001", [this.namespace, enrollmentId]);
|
|
45
|
+
for (const row of operations)
|
|
46
|
+
rows.set(JSON.stringify(['operation', row.id]), JSON.parse(row.body));
|
|
47
|
+
if (operations.length > 1000)
|
|
48
|
+
throw new Error('provider aggregate exceeds retained operation limit');
|
|
49
|
+
if (selection.kind === 'begin') {
|
|
50
|
+
const states = new Set();
|
|
51
|
+
if (selection.stateHash)
|
|
52
|
+
states.add(selection.stateHash);
|
|
53
|
+
for (const row of operations) {
|
|
54
|
+
const hash = JSON.parse(row.body).binding?.stateHash;
|
|
55
|
+
if (hash)
|
|
56
|
+
states.add(hash);
|
|
57
|
+
}
|
|
58
|
+
for (const hash of [...states].sort()) {
|
|
59
|
+
await lock('state', hash);
|
|
60
|
+
await one('state', hash);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
if (selection.kind === 'callback') {
|
|
66
|
+
// Re-resolve after the enrollment lock; cleanup may have removed/rebound the selector.
|
|
67
|
+
const current = await one('state', selection.stateHash);
|
|
68
|
+
if (json(current ?? null) !== json(lookup ?? null))
|
|
69
|
+
throw new Error('provider callback selector changed');
|
|
70
|
+
}
|
|
71
|
+
const operationId = selection.kind === 'callback' ? lookup?.operationId : 'operationId' in selection ? selection.operationId : undefined;
|
|
72
|
+
if (operationId)
|
|
73
|
+
await one('operation', `${enrollmentId}:${operationId}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const before = new Map([...rows].map(([key, value]) => [key, json(value)]));
|
|
78
|
+
const result = synchronous(action(scopedProviderTransaction(selection, providerMemoryTransaction(rows))));
|
|
79
|
+
const removed = [...before.keys()].filter(key => !rows.has(key)).map(key => { const [kind, id] = JSON.parse(key); return { kind, id }; });
|
|
80
|
+
if (removed.length)
|
|
81
|
+
await this.db.query('DELETE FROM poppy_provider.records r USING jsonb_to_recordset($2::jsonb) AS d(kind text,id text) WHERE r.ns=$1 AND r.kind=d.kind AND r.id=d.id', [this.namespace, json(removed)]);
|
|
82
|
+
const changed = [];
|
|
83
|
+
for (const [key, value] of rows) {
|
|
84
|
+
const body = json(value);
|
|
85
|
+
if (before.get(key) === body)
|
|
86
|
+
continue;
|
|
87
|
+
const [kind, id] = JSON.parse(key);
|
|
88
|
+
changed.push({ kind, id, body });
|
|
89
|
+
}
|
|
90
|
+
if (changed.length)
|
|
91
|
+
await this.db.query('INSERT INTO poppy_provider.records(ns,kind,id,body) SELECT $1,kind,id,body FROM jsonb_to_recordset($2::jsonb) AS d(kind text,id text,body text) ON CONFLICT(ns,kind,id) DO UPDATE SET body=excluded.body', [this.namespace, json(changed)]);
|
|
92
|
+
this.metrics.rowsWritten += removed.length + changed.length;
|
|
93
|
+
return result;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ProviderStore, ProviderTransaction } from './provider.js';
|
|
1
|
+
import type { ProviderSelection, ProviderStore, ProviderTransaction } from './provider.js';
|
|
2
2
|
/** Optional durable provider records. Callback results must be encrypted by the separately configured cipher. */
|
|
3
3
|
export declare class SqliteProviderStore implements ProviderStore {
|
|
4
4
|
private readonly db;
|
|
5
5
|
constructor(path: string);
|
|
6
6
|
close(): void;
|
|
7
|
-
transaction<T>(action: (tx: ProviderTransaction) => T): T
|
|
7
|
+
transaction<T>(selection: ProviderSelection, action: (tx: ProviderTransaction) => T): Promise<T>;
|
|
8
8
|
}
|
package/dist/provider-sqlite.js
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { initializeSqlite } from './sqlite-init.js';
|
|
3
|
+
import { scopedProviderTransaction } from './provider-storage.js';
|
|
3
4
|
/** Optional durable provider records. Callback results must be encrypted by the separately configured cipher. */
|
|
4
5
|
export class SqliteProviderStore {
|
|
5
6
|
db;
|
|
6
7
|
constructor(path) {
|
|
7
8
|
this.db = new DatabaseSync(path);
|
|
8
9
|
initializeSqlite(this.db, () => this.db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS provider_records(kind TEXT NOT NULL,id TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(kind,id));'));
|
|
10
|
+
this.db.exec(`INSERT OR IGNORE INTO provider_records SELECT 'capacity','enrollments',json_object('count',count(*)) FROM provider_records WHERE kind='enrollment'`);
|
|
9
11
|
}
|
|
10
12
|
close() { this.db.close(); }
|
|
11
|
-
transaction(action) {
|
|
13
|
+
async transaction(selection, action) {
|
|
12
14
|
this.db.exec('BEGIN IMMEDIATE');
|
|
13
15
|
try {
|
|
14
|
-
const result = action({
|
|
16
|
+
const result = action(scopedProviderTransaction(selection, {
|
|
15
17
|
get: (kind, id) => { const row = this.db.prepare('SELECT body FROM provider_records WHERE kind=? AND id=?').get(kind, id); return row ? JSON.parse(row.body) : undefined; },
|
|
16
18
|
put: (kind, id, value) => { this.db.prepare('INSERT OR REPLACE INTO provider_records VALUES (?,?,?)').run(kind, id, JSON.stringify(value)); },
|
|
17
19
|
remove: (kind, id) => { this.db.prepare('DELETE FROM provider_records WHERE kind=? AND id=?').run(kind, id); },
|
|
18
|
-
list: (kind, prefix, limit) => this.db.prepare('SELECT id,body FROM provider_records WHERE kind=? AND id>=? AND id<? ORDER BY id LIMIT ?').all(kind, prefix, `${prefix}
|
|
19
|
-
});
|
|
20
|
-
if (result
|
|
20
|
+
list: (kind, prefix, limit) => this.db.prepare('SELECT id,body FROM provider_records WHERE kind=? AND id>=? AND id<? ORDER BY id LIMIT ?').all(kind, prefix, `${prefix.slice(0, -1)};`, limit).map(row => ({ id: row.id, value: JSON.parse(row.body) })),
|
|
21
|
+
}));
|
|
22
|
+
if (result && typeof result.then === 'function')
|
|
21
23
|
throw new TypeError('provider transaction must be synchronous');
|
|
24
|
+
const detached = structuredClone(result);
|
|
22
25
|
this.db.exec('COMMIT');
|
|
23
|
-
return
|
|
26
|
+
return detached;
|
|
24
27
|
}
|
|
25
28
|
catch (error) {
|
|
26
29
|
this.db.exec('ROLLBACK');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ProviderSelection, ProviderTransaction } from './provider.js';
|
|
2
|
+
type Row = Record<string, any>;
|
|
3
|
+
export declare const providerUserKey: (visitor: string, issuer: string) => string;
|
|
4
|
+
/** Enforces the same aggregate boundary for local, remote and custom stores. */
|
|
5
|
+
export declare function scopedProviderTransaction(selection: ProviderSelection, raw: ProviderTransaction): ProviderTransaction;
|
|
6
|
+
export declare function providerMemoryTransaction(rows: Map<string, Row>): ProviderTransaction;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const providerUserKey = (visitor, issuer) => createHash('sha256').update(JSON.stringify([visitor, issuer])).digest('base64url');
|
|
3
|
+
/** Enforces the same aggregate boundary for local, remote and custom stores. */
|
|
4
|
+
export function scopedProviderTransaction(selection, raw) {
|
|
5
|
+
let enrollmentId = 'enrollmentId' in selection ? selection.enrollmentId : undefined;
|
|
6
|
+
let operationId = 'operationId' in selection ? selection.operationId : undefined;
|
|
7
|
+
if (selection.kind === 'callback') {
|
|
8
|
+
const lookup = raw.get('state', selection.stateHash);
|
|
9
|
+
enrollmentId = lookup?.enrollmentId;
|
|
10
|
+
operationId = lookup?.operationId;
|
|
11
|
+
}
|
|
12
|
+
const enrollment = enrollmentId ? raw.get('enrollment', enrollmentId) : undefined;
|
|
13
|
+
const userKey = 'issuer' in selection && enrollment ? providerUserKey(enrollment.visitor, selection.issuer) : undefined;
|
|
14
|
+
const states = new Set();
|
|
15
|
+
if (selection.kind === 'callback')
|
|
16
|
+
states.add(selection.stateHash);
|
|
17
|
+
if (selection.kind === 'begin') {
|
|
18
|
+
if (selection.stateHash)
|
|
19
|
+
states.add(selection.stateHash);
|
|
20
|
+
for (const row of raw.list('operation', `${enrollmentId}:`, 1001))
|
|
21
|
+
if (row.value.binding?.stateHash)
|
|
22
|
+
states.add(row.value.binding.stateHash);
|
|
23
|
+
}
|
|
24
|
+
let createdEnrollment;
|
|
25
|
+
const check = (kind, id, write = false) => {
|
|
26
|
+
let allowed = false;
|
|
27
|
+
if (selection.kind === 'enroll') {
|
|
28
|
+
allowed = (kind === 'capacity' && id === 'enrollments') || (kind === 'visitor' && id === selection.subjectHash) || (kind === 'enrollmentRequest' && id === selection.requestHash);
|
|
29
|
+
if (kind === 'enrollment' && write && (!createdEnrollment || createdEnrollment === id) && !raw.get(kind, id)) {
|
|
30
|
+
createdEnrollment = id;
|
|
31
|
+
allowed = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
allowed = (kind === 'enrollment' && id === enrollmentId) || (kind === 'user' && id === userKey) || (kind === 'state' && states.has(id));
|
|
36
|
+
if (kind === 'operation')
|
|
37
|
+
allowed = (selection.kind === 'begin' || selection.kind === 'revoke') ? id.startsWith(`${enrollmentId}:`) : id === `${enrollmentId}:${operationId}`;
|
|
38
|
+
}
|
|
39
|
+
if (!allowed)
|
|
40
|
+
throw new TypeError('provider access outside selection');
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
get(kind, id) { check(kind, id); return raw.get(kind, id); },
|
|
44
|
+
put(kind, id, value) { check(kind, id, true); raw.put(kind, id, value); },
|
|
45
|
+
remove(kind, id) { check(kind, id); raw.remove(kind, id); },
|
|
46
|
+
list(kind, prefix, limit) {
|
|
47
|
+
if (!['begin', 'revoke'].includes(selection.kind) || kind !== 'operation' || prefix !== `${enrollmentId}:` || !Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
|
|
48
|
+
throw new TypeError('provider list outside selection');
|
|
49
|
+
return raw.list(kind, prefix, limit);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function providerMemoryTransaction(rows) {
|
|
54
|
+
const key = (kind, id) => JSON.stringify([kind, id]);
|
|
55
|
+
return {
|
|
56
|
+
get: (kind, id) => rows.get(key(kind, id)),
|
|
57
|
+
put: (kind, id, value) => { rows.set(key(kind, id), value); },
|
|
58
|
+
remove: (kind, id) => { rows.delete(key(kind, id)); },
|
|
59
|
+
list: (kind, prefix, limit) => [...rows].map(([key, value]) => ({ key: JSON.parse(key), value })).filter(row => row.key[0] === kind && row.key[1].startsWith(prefix)).sort((a, b) => a.key[1].localeCompare(b.key[1])).slice(0, limit).map(row => ({ id: row.key[1], value: row.value })),
|
|
60
|
+
};
|
|
61
|
+
}
|