@usearete/automations 0.0.0-stage → 0.44.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 +21 -0
- package/README.md +299 -2
- package/dist/cli.js +4518 -0
- package/dist/index.cjs +3735 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +1379 -0
- package/dist/index.js +3690 -0
- package/dist/index.js.map +1 -0
- package/dist/keypair.cjs +267 -0
- package/dist/keypair.cjs.map +1 -0
- package/dist/keypair.d.ts +70 -0
- package/dist/keypair.js +265 -0
- package/dist/keypair.js.map +1 -0
- package/dist/postgres.cjs +757 -0
- package/dist/postgres.cjs.map +1 -0
- package/dist/postgres.d.ts +449 -0
- package/dist/postgres.js +749 -0
- package/dist/postgres.js.map +1 -0
- package/dist/privy.cjs +618 -0
- package/dist/privy.cjs.map +1 -0
- package/dist/privy.d.ts +236 -0
- package/dist/privy.js +614 -0
- package/dist/privy.js.map +1 -0
- package/dist/testing.cjs +467 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.ts +381 -0
- package/dist/testing.js +465 -0
- package/dist/testing.js.map +1 -0
- package/package.json +108 -4
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ACTIVE_STEP_STATES$1 = [
|
|
4
|
+
'running',
|
|
5
|
+
'signed',
|
|
6
|
+
'submitted',
|
|
7
|
+
'submitted_unknown',
|
|
8
|
+
];
|
|
9
|
+
const TERMINAL_STEP_STATES = [
|
|
10
|
+
'completed',
|
|
11
|
+
'chain_failed',
|
|
12
|
+
'expired',
|
|
13
|
+
'skipped',
|
|
14
|
+
'rejected',
|
|
15
|
+
'refused',
|
|
16
|
+
'failed',
|
|
17
|
+
];
|
|
18
|
+
const TERMINAL_DELIVERY_STATES = ['done', 'failed'];
|
|
19
|
+
|
|
20
|
+
/** An append-view cursor `{epoch}:{offset}` as numbers, or `null` when it is not one. */
|
|
21
|
+
function parseCursor(cursor) {
|
|
22
|
+
// The epoch is opaque (the server uses a UUID per tape lifetime); the
|
|
23
|
+
// offset follows the last colon.
|
|
24
|
+
const at = cursor.lastIndexOf(':');
|
|
25
|
+
const epoch = cursor.slice(0, at);
|
|
26
|
+
const offset = cursor.slice(at + 1);
|
|
27
|
+
if (at <= 0 || !/^\d+$/.test(offset))
|
|
28
|
+
return null;
|
|
29
|
+
return { epoch, offset: BigInt(offset) };
|
|
30
|
+
}
|
|
31
|
+
class StoreDeploymentError extends Error {
|
|
32
|
+
constructor(bound, requested) {
|
|
33
|
+
super(`this store belongs to deployment ${bound}, not ${requested}; use one store per deployment`);
|
|
34
|
+
this.name = 'StoreDeploymentError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Versioned SQL migrations for the Postgres store. Every object lives in
|
|
40
|
+
* one schema (`arete_automation` by default); nothing outside it is
|
|
41
|
+
* created, altered or read. Times are milliseconds since the epoch.
|
|
42
|
+
*/
|
|
43
|
+
const ACTIVE_STEP_STATES = "('running', 'signed', 'submitted', 'submitted_unknown')";
|
|
44
|
+
const MIGRATIONS = [
|
|
45
|
+
{
|
|
46
|
+
version: 1,
|
|
47
|
+
name: 'initial',
|
|
48
|
+
sql: (s) => `
|
|
49
|
+
CREATE TABLE ${s}.meta (
|
|
50
|
+
key text PRIMARY KEY,
|
|
51
|
+
value text NOT NULL
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE ${s}.deliveries (
|
|
55
|
+
id text PRIMARY KEY,
|
|
56
|
+
accepted_at bigint NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE ${s}.jobs (
|
|
60
|
+
id text PRIMARY KEY,
|
|
61
|
+
seq bigserial NOT NULL,
|
|
62
|
+
kind text NOT NULL CHECK (kind IN ('delivery', 'step')),
|
|
63
|
+
state text NOT NULL,
|
|
64
|
+
wallet text,
|
|
65
|
+
deployment_id text,
|
|
66
|
+
step text,
|
|
67
|
+
data jsonb NOT NULL,
|
|
68
|
+
outcome jsonb,
|
|
69
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
70
|
+
not_before bigint NOT NULL,
|
|
71
|
+
lease_worker text,
|
|
72
|
+
lease_until bigint,
|
|
73
|
+
created_at bigint NOT NULL,
|
|
74
|
+
updated_at bigint NOT NULL,
|
|
75
|
+
CHECK (kind = 'delivery' OR wallet IS NOT NULL)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
-- At most one active step job per wallet: the store's per-wallet serialization.
|
|
79
|
+
CREATE UNIQUE INDEX jobs_one_active_step_per_wallet ON ${s}.jobs (wallet)
|
|
80
|
+
WHERE kind = 'step' AND state IN ${ACTIVE_STEP_STATES};
|
|
81
|
+
|
|
82
|
+
CREATE INDEX jobs_claimable ON ${s}.jobs (not_before, seq)
|
|
83
|
+
WHERE state IN ('queued', 'running', 'signed', 'submitted', 'submitted_unknown');
|
|
84
|
+
|
|
85
|
+
CREATE INDEX jobs_completed_steps ON ${s}.jobs (wallet, deployment_id, step, updated_at)
|
|
86
|
+
WHERE kind = 'step' AND state = 'completed';
|
|
87
|
+
|
|
88
|
+
CREATE INDEX jobs_finished ON ${s}.jobs (updated_at)
|
|
89
|
+
WHERE state IN ('done', 'failed', 'completed', 'chain_failed', 'expired', 'skipped', 'rejected', 'refused');
|
|
90
|
+
|
|
91
|
+
CREATE TABLE ${s}.consents (
|
|
92
|
+
wallet text NOT NULL,
|
|
93
|
+
deployment_id text NOT NULL,
|
|
94
|
+
-- '' for a grant that covers every instance.
|
|
95
|
+
instance_id text NOT NULL,
|
|
96
|
+
steps text[] NOT NULL,
|
|
97
|
+
expires_at bigint NOT NULL,
|
|
98
|
+
revoked_at bigint,
|
|
99
|
+
grant_json jsonb NOT NULL,
|
|
100
|
+
PRIMARY KEY (wallet, deployment_id, instance_id)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
CREATE INDEX consents_active ON ${s}.consents (deployment_id, expires_at) WHERE revoked_at IS NULL;
|
|
104
|
+
|
|
105
|
+
CREATE TABLE ${s}.consent_nonces (
|
|
106
|
+
nonce text PRIMARY KEY,
|
|
107
|
+
expires_at bigint NOT NULL,
|
|
108
|
+
used_at bigint
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE ${s}.ledger (
|
|
112
|
+
id bigserial PRIMARY KEY,
|
|
113
|
+
job_id text NOT NULL,
|
|
114
|
+
wallet text NOT NULL,
|
|
115
|
+
deployment_id text NOT NULL,
|
|
116
|
+
asset text NOT NULL,
|
|
117
|
+
amount numeric(39, 0) NOT NULL CHECK (amount >= 0),
|
|
118
|
+
recorded_at bigint NOT NULL,
|
|
119
|
+
voided_at bigint
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE INDEX ledger_window ON ${s}.ledger (wallet, deployment_id, asset, recorded_at) WHERE voided_at IS NULL;
|
|
123
|
+
CREATE INDEX ledger_job ON ${s}.ledger (job_id);
|
|
124
|
+
|
|
125
|
+
CREATE TABLE ${s}.source_state (
|
|
126
|
+
source text NOT NULL,
|
|
127
|
+
key text NOT NULL,
|
|
128
|
+
hash text NOT NULL,
|
|
129
|
+
value jsonb,
|
|
130
|
+
generation integer NOT NULL,
|
|
131
|
+
pending jsonb,
|
|
132
|
+
observed text,
|
|
133
|
+
version integer NOT NULL,
|
|
134
|
+
updated_at bigint NOT NULL,
|
|
135
|
+
PRIMARY KEY (source, key)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
CREATE TABLE ${s}.source_cursors (
|
|
139
|
+
source text PRIMARY KEY,
|
|
140
|
+
cursor text NOT NULL,
|
|
141
|
+
updated_at bigint NOT NULL
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
CREATE TABLE ${s}.source_leases (
|
|
145
|
+
source text PRIMARY KEY,
|
|
146
|
+
holder text NOT NULL,
|
|
147
|
+
until bigint NOT NULL
|
|
148
|
+
);
|
|
149
|
+
`,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
153
|
+
const DEFAULT_SCHEMA = 'arete_automation';
|
|
154
|
+
/** A schema name: lower case letters, digits and underscores. */
|
|
155
|
+
function validSchema(schema) {
|
|
156
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(schema)) {
|
|
157
|
+
throw new TypeError(`'${schema}' is not a valid schema name (lower case letters, digits and underscores)`);
|
|
158
|
+
}
|
|
159
|
+
return schema;
|
|
160
|
+
}
|
|
161
|
+
/** A valid name as a quoted identifier, so reserved words (`user`, `select`) work too. */
|
|
162
|
+
function quoted(name) {
|
|
163
|
+
return `"${name}"`;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The migrations as files for your own migration tool (Flyway, Prisma,
|
|
167
|
+
* Drizzle, Knex, Atlas): `0001_initial.sql` and so on. Each creates the
|
|
168
|
+
* schema if needed and records its version, as `migrate()` does.
|
|
169
|
+
*/
|
|
170
|
+
function migrationFiles(schema = DEFAULT_SCHEMA) {
|
|
171
|
+
const s = quoted(validSchema(schema));
|
|
172
|
+
return MIGRATIONS.map((migration) => ({
|
|
173
|
+
name: `${String(migration.version).padStart(4, '0')}_${migration.name}.sql`,
|
|
174
|
+
sql: [
|
|
175
|
+
`-- @usearete/automations migration ${migration.version}: ${migration.name}`,
|
|
176
|
+
`CREATE SCHEMA IF NOT EXISTS ${s};`,
|
|
177
|
+
`CREATE TABLE IF NOT EXISTS ${s}.schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());`,
|
|
178
|
+
migration.sql(s).trim(),
|
|
179
|
+
`INSERT INTO ${s}.schema_migrations (version) VALUES (${migration.version});`,
|
|
180
|
+
'',
|
|
181
|
+
].join('\n'),
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Grants for the runtime role: read and write rows, no DDL. Run as the role
|
|
186
|
+
* that owns the schema, after migrating.
|
|
187
|
+
*/
|
|
188
|
+
function runtimeRoleGrants(role, schema = DEFAULT_SCHEMA) {
|
|
189
|
+
const s = quoted(validSchema(schema));
|
|
190
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(role))
|
|
191
|
+
throw new TypeError(`'${role}' is not a valid role name`);
|
|
192
|
+
role = quoted(role);
|
|
193
|
+
return [
|
|
194
|
+
`GRANT USAGE ON SCHEMA ${s} TO ${role};`,
|
|
195
|
+
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${s} TO ${role};`,
|
|
196
|
+
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${role};`,
|
|
197
|
+
`REVOKE CREATE ON SCHEMA ${s} FROM ${role};`,
|
|
198
|
+
'',
|
|
199
|
+
].join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The Postgres store. It runs in your existing database, in its own schema,
|
|
204
|
+
* through the pool (or client) you pass: it opens no connections of its
|
|
205
|
+
* own and creates nothing outside its schema.
|
|
206
|
+
*
|
|
207
|
+
* Migrations never run at startup. Run `store.migrate()` (with a role that
|
|
208
|
+
* may create objects) or apply the SQL from `migrationFiles()` with your
|
|
209
|
+
* own tool; a store whose database is behind refuses to run.
|
|
210
|
+
*/
|
|
211
|
+
class SchemaVersionError extends Error {
|
|
212
|
+
found;
|
|
213
|
+
expected;
|
|
214
|
+
constructor(found, expected, schema) {
|
|
215
|
+
super(`the ${schema} schema is at version ${found}, and this package needs ${expected}; run store.migrate() or apply migrationFiles()`);
|
|
216
|
+
this.name = 'SchemaVersionError';
|
|
217
|
+
this.found = found;
|
|
218
|
+
this.expected = expected;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const UNIQUE_VIOLATION = '23505';
|
|
222
|
+
const ACTIVE = `('${ACTIVE_STEP_STATES$1.join("', '")}')`;
|
|
223
|
+
const FINISHED = `('${[...TERMINAL_STEP_STATES, ...TERMINAL_DELIVERY_STATES].join("', '")}')`;
|
|
224
|
+
/**
|
|
225
|
+
* A pool hands out clients. Clients have `connect()` too (to open their
|
|
226
|
+
* connection), so a pool is recognized by its pool counters.
|
|
227
|
+
*/
|
|
228
|
+
function isPool(executor) {
|
|
229
|
+
return typeof executor.connect === 'function' && 'totalCount' in executor;
|
|
230
|
+
}
|
|
231
|
+
function code(error) {
|
|
232
|
+
return error?.code;
|
|
233
|
+
}
|
|
234
|
+
/** JSON for a jsonb parameter: undefined fields become null so a merge can clear them. */
|
|
235
|
+
function json(value) {
|
|
236
|
+
return JSON.stringify(value, (_key, item) => (item === undefined ? null : typeof item === 'bigint' ? item.toString() : item));
|
|
237
|
+
}
|
|
238
|
+
/** Progress fields given as undefined or null are cleared; the rest are merged. */
|
|
239
|
+
function clearedKeys(progress) {
|
|
240
|
+
return Object.entries(progress)
|
|
241
|
+
.filter(([, value]) => value === undefined || value === null)
|
|
242
|
+
.map(([key]) => key);
|
|
243
|
+
}
|
|
244
|
+
function progressPatch(progress) {
|
|
245
|
+
return Object.fromEntries(Object.entries(progress).filter(([, value]) => value !== undefined && value !== null));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Operations queued per client. A client runs one statement at a time, but
|
|
249
|
+
* two operations sharing it would interleave: one's savepoint release would
|
|
250
|
+
* also release the other's, and a statement could land inside another
|
|
251
|
+
* operation's savepoint and roll back with it. So operations on one client
|
|
252
|
+
* run one after another, across every store built over that client.
|
|
253
|
+
*/
|
|
254
|
+
const clientQueues = new WeakMap();
|
|
255
|
+
function onClient(client, operation) {
|
|
256
|
+
const previous = clientQueues.get(client) ?? Promise.resolve();
|
|
257
|
+
const run = previous.then(operation, operation);
|
|
258
|
+
clientQueues.set(client, run.then(() => undefined, () => undefined));
|
|
259
|
+
return run;
|
|
260
|
+
}
|
|
261
|
+
/** `store` with each operation queued on `client`. */
|
|
262
|
+
function queuedOn(client, store) {
|
|
263
|
+
const wrap = (section) => Object.fromEntries(Object.entries(section).map(([name, value]) => [
|
|
264
|
+
name,
|
|
265
|
+
typeof value === 'function' ? (...args) => onClient(client, () => value(...args)) : value,
|
|
266
|
+
]));
|
|
267
|
+
return {
|
|
268
|
+
...wrap(store),
|
|
269
|
+
using: store.using,
|
|
270
|
+
deliveries: wrap(store.deliveries),
|
|
271
|
+
jobs: wrap(store.jobs),
|
|
272
|
+
consent: wrap(store.consent),
|
|
273
|
+
sources: wrap(store.sources),
|
|
274
|
+
ledger: wrap(store.ledger),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const num = (value) => Number(value);
|
|
278
|
+
const numOrNull = (value) => (value === null || value === undefined ? null : Number(value));
|
|
279
|
+
function rowToJob(row) {
|
|
280
|
+
const base = {
|
|
281
|
+
id: row.id,
|
|
282
|
+
attempts: num(row.attempts),
|
|
283
|
+
notBefore: num(row.not_before),
|
|
284
|
+
lease: row.lease_worker === null ? null : { worker: row.lease_worker, until: num(row.lease_until) },
|
|
285
|
+
createdAt: num(row.created_at),
|
|
286
|
+
updatedAt: num(row.updated_at),
|
|
287
|
+
outcome: row.outcome ?? null,
|
|
288
|
+
};
|
|
289
|
+
if (row.kind === 'delivery') {
|
|
290
|
+
return { ...base, kind: 'delivery', state: row.state, delivery: row.data.delivery };
|
|
291
|
+
}
|
|
292
|
+
const progress = Object.fromEntries(Object.entries(row.data.progress ?? {}).filter(([, value]) => value !== null));
|
|
293
|
+
return {
|
|
294
|
+
...base,
|
|
295
|
+
kind: 'step',
|
|
296
|
+
state: row.state,
|
|
297
|
+
deliveryId: row.data.deliveryId,
|
|
298
|
+
deploymentId: row.data.deploymentId,
|
|
299
|
+
request: row.data.request,
|
|
300
|
+
progress: { generation: 0, ...progress },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function rowToGrant(row) {
|
|
304
|
+
return { ...row.grant_json, steps: row.steps, revokedAt: numOrNull(row.revoked_at) };
|
|
305
|
+
}
|
|
306
|
+
function rowToSource(row) {
|
|
307
|
+
return {
|
|
308
|
+
source: row.source,
|
|
309
|
+
key: row.key,
|
|
310
|
+
hash: row.hash,
|
|
311
|
+
value: row.value,
|
|
312
|
+
generation: num(row.generation),
|
|
313
|
+
pending: row.pending ?? null,
|
|
314
|
+
observed: row.observed ?? null,
|
|
315
|
+
version: num(row.version),
|
|
316
|
+
updatedAt: num(row.updated_at),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function postgresStore(executor, options = {}) {
|
|
320
|
+
const internal = options;
|
|
321
|
+
if (options.executor === 'pool' && typeof executor.connect !== 'function') {
|
|
322
|
+
throw new TypeError("executor: 'pool' needs an executor with connect()");
|
|
323
|
+
}
|
|
324
|
+
const clientMode = internal.client === true ||
|
|
325
|
+
options.executor === 'client' ||
|
|
326
|
+
(options.executor !== 'pool' && !isPool(executor));
|
|
327
|
+
const name = validSchema(options.schema ?? DEFAULT_SCHEMA);
|
|
328
|
+
const s = quoted(name);
|
|
329
|
+
let checked = null;
|
|
330
|
+
/** Run `fn` atomically: a transaction on a pool, a savepoint on a client. */
|
|
331
|
+
const atomic = async (fn) => {
|
|
332
|
+
if (!clientMode) {
|
|
333
|
+
const client = await executor.connect();
|
|
334
|
+
try {
|
|
335
|
+
await client.query('BEGIN');
|
|
336
|
+
const result = await fn(client);
|
|
337
|
+
await client.query('COMMIT');
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
client.release();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const savepoint = `arete_${Math.random().toString(36).slice(2, 10)}`;
|
|
349
|
+
await executor.query(`SAVEPOINT ${savepoint}`);
|
|
350
|
+
try {
|
|
351
|
+
const result = await fn(executor);
|
|
352
|
+
await executor.query(`RELEASE SAVEPOINT ${savepoint}`);
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
await executor.query(`ROLLBACK TO SAVEPOINT ${savepoint}`).catch(() => undefined);
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const schemaVersion = async () => {
|
|
361
|
+
// Look the table up first: querying a missing one would raise an error
|
|
362
|
+
// that leaves a caller's transaction aborted.
|
|
363
|
+
const { rows: found } = await executor.query('SELECT to_regclass($1) IS NOT NULL AS present', [`${s}.schema_migrations`]);
|
|
364
|
+
if (!found[0]?.present)
|
|
365
|
+
return 0;
|
|
366
|
+
const { rows } = await executor.query(`SELECT coalesce(max(version), 0) AS version FROM ${s}.schema_migrations`);
|
|
367
|
+
return num(rows[0]?.version ?? 0);
|
|
368
|
+
};
|
|
369
|
+
const checkVersion = async () => {
|
|
370
|
+
const found = await schemaVersion();
|
|
371
|
+
if (found < LATEST_SCHEMA_VERSION)
|
|
372
|
+
throw new SchemaVersionError(found, LATEST_SCHEMA_VERSION, name);
|
|
373
|
+
};
|
|
374
|
+
/** Every operation waits for one successful version check. */
|
|
375
|
+
const ready = async () => {
|
|
376
|
+
checked ??= checkVersion().catch((error) => {
|
|
377
|
+
checked = null;
|
|
378
|
+
throw error;
|
|
379
|
+
});
|
|
380
|
+
await checked;
|
|
381
|
+
};
|
|
382
|
+
const acceptIn = async (db, delivery, now) => {
|
|
383
|
+
const inserted = await db.query(`INSERT INTO ${s}.deliveries (id, accepted_at) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, [delivery.id, now]);
|
|
384
|
+
if (inserted.rowCount !== 1)
|
|
385
|
+
return false;
|
|
386
|
+
// The delivery id may have been pruned while its job remains: a duplicate.
|
|
387
|
+
const job = await db.query(`INSERT INTO ${s}.jobs (id, kind, state, data, not_before, created_at, updated_at)
|
|
388
|
+
VALUES ($1, 'delivery', 'queued', $2::jsonb, $3, $3, $3)
|
|
389
|
+
ON CONFLICT (id) DO NOTHING`, [delivery.id, json({ delivery }), now]);
|
|
390
|
+
return job.rowCount === 1;
|
|
391
|
+
};
|
|
392
|
+
const claimOnce = async (db, { worker, leaseMs, now }) => {
|
|
393
|
+
const { rows } = await db.query(`WITH candidate AS (
|
|
394
|
+
SELECT j.id FROM ${s}.jobs j
|
|
395
|
+
WHERE j.not_before <= $1
|
|
396
|
+
AND (j.lease_until IS NULL OR j.lease_until <= $1)
|
|
397
|
+
AND (
|
|
398
|
+
(j.kind = 'delivery' AND j.state IN ('queued', 'running'))
|
|
399
|
+
OR (j.kind = 'step' AND j.state IN ${ACTIVE})
|
|
400
|
+
OR (j.kind = 'step' AND j.state = 'queued' AND NOT EXISTS (
|
|
401
|
+
SELECT 1 FROM ${s}.jobs a
|
|
402
|
+
WHERE a.kind = 'step' AND a.wallet = j.wallet AND a.id <> j.id AND a.state IN ${ACTIVE}))
|
|
403
|
+
)
|
|
404
|
+
ORDER BY j.not_before, j.seq
|
|
405
|
+
LIMIT 1
|
|
406
|
+
FOR UPDATE SKIP LOCKED
|
|
407
|
+
)
|
|
408
|
+
UPDATE ${s}.jobs j
|
|
409
|
+
SET state = CASE WHEN j.state = 'queued' THEN 'running' ELSE j.state END,
|
|
410
|
+
lease_worker = $2, lease_until = $3, updated_at = $1
|
|
411
|
+
FROM candidate WHERE j.id = candidate.id
|
|
412
|
+
RETURNING j.*`, [now, worker, now + leaseMs]);
|
|
413
|
+
return rows[0] ? rowToJob(rows[0]) : null;
|
|
414
|
+
};
|
|
415
|
+
const store = {
|
|
416
|
+
schema: name,
|
|
417
|
+
schemaVersion,
|
|
418
|
+
checkVersion,
|
|
419
|
+
using: (client) => postgresStore(client, { schema: name, executor: 'client', client: true }),
|
|
420
|
+
async bindDeployment(deploymentId) {
|
|
421
|
+
await ready();
|
|
422
|
+
await executor.query(`INSERT INTO ${s}.meta (key, value) VALUES ('deployment', $1) ON CONFLICT (key) DO NOTHING`, [
|
|
423
|
+
deploymentId,
|
|
424
|
+
]);
|
|
425
|
+
const { rows } = await executor.query(`SELECT value FROM ${s}.meta WHERE key = 'deployment'`);
|
|
426
|
+
const bound = rows[0]?.value;
|
|
427
|
+
if (bound !== deploymentId)
|
|
428
|
+
throw new StoreDeploymentError(bound, deploymentId);
|
|
429
|
+
},
|
|
430
|
+
async migrate() {
|
|
431
|
+
return atomic(async (db) => {
|
|
432
|
+
await db.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`@usearete/automations:${name}`]);
|
|
433
|
+
await db.query(`CREATE SCHEMA IF NOT EXISTS ${s}`);
|
|
434
|
+
await db.query(`CREATE TABLE IF NOT EXISTS ${s}.schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`);
|
|
435
|
+
const { rows } = await db.query(`SELECT coalesce(max(version), 0) AS version FROM ${s}.schema_migrations`);
|
|
436
|
+
const from = num(rows[0].version);
|
|
437
|
+
for (const migration of MIGRATIONS) {
|
|
438
|
+
if (migration.version <= from)
|
|
439
|
+
continue;
|
|
440
|
+
await db.query(migration.sql(s));
|
|
441
|
+
await db.query(`INSERT INTO ${s}.schema_migrations (version) VALUES ($1)`, [migration.version]);
|
|
442
|
+
}
|
|
443
|
+
checked = null;
|
|
444
|
+
return { from, to: Math.max(from, LATEST_SCHEMA_VERSION) };
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
deliveries: {
|
|
448
|
+
async accept(delivery, now) {
|
|
449
|
+
await ready();
|
|
450
|
+
return atomic((db) => acceptIn(db, delivery, now));
|
|
451
|
+
},
|
|
452
|
+
async prune(before) {
|
|
453
|
+
await ready();
|
|
454
|
+
const result = await executor.query(`DELETE FROM ${s}.deliveries WHERE accepted_at < $1`, [before]);
|
|
455
|
+
return result.rowCount ?? 0;
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
jobs: {
|
|
459
|
+
async get(id) {
|
|
460
|
+
await ready();
|
|
461
|
+
const { rows } = await executor.query(`SELECT * FROM ${s}.jobs WHERE id = $1`, [id]);
|
|
462
|
+
return rows[0] ? rowToJob(rows[0]) : null;
|
|
463
|
+
},
|
|
464
|
+
async claimNext(input) {
|
|
465
|
+
await ready();
|
|
466
|
+
// A concurrent claim can start another job of the same wallet; the
|
|
467
|
+
// unique index refuses the second, which then looks again.
|
|
468
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
469
|
+
try {
|
|
470
|
+
// In its own transaction (a savepoint inside a caller's), so a lost
|
|
471
|
+
// race never leaves the caller's transaction aborted.
|
|
472
|
+
return await atomic((db) => claimOnce(db, input));
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
if (code(error) !== UNIQUE_VIOLATION)
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
},
|
|
481
|
+
async transition(id, input) {
|
|
482
|
+
await ready();
|
|
483
|
+
try {
|
|
484
|
+
return await atomic(async (db) => {
|
|
485
|
+
const result = await db.query(`UPDATE ${s}.jobs SET
|
|
486
|
+
state = $4,
|
|
487
|
+
updated_at = $5,
|
|
488
|
+
lease_worker = CASE WHEN $6::bigint IS NULL THEN NULL ELSE $3 END,
|
|
489
|
+
lease_until = $6::bigint,
|
|
490
|
+
not_before = coalesce($7::bigint, not_before),
|
|
491
|
+
attempts = coalesce($8::integer, attempts),
|
|
492
|
+
outcome = CASE WHEN $9::boolean THEN $10::jsonb ELSE outcome END,
|
|
493
|
+
data = CASE WHEN $11::jsonb IS NULL THEN data
|
|
494
|
+
ELSE jsonb_set(data, '{progress}', (coalesce(data->'progress', '{}'::jsonb) || $11::jsonb) - $12::text[]) END
|
|
495
|
+
WHERE id = $1 AND state = $2 AND lease_worker = $3`, [
|
|
496
|
+
id,
|
|
497
|
+
input.from,
|
|
498
|
+
input.worker,
|
|
499
|
+
input.to,
|
|
500
|
+
input.now,
|
|
501
|
+
input.keepLease?.until ?? null,
|
|
502
|
+
input.notBefore ?? null,
|
|
503
|
+
input.attempts ?? null,
|
|
504
|
+
input.outcome !== undefined,
|
|
505
|
+
input.outcome === undefined || input.outcome === null ? null : json(input.outcome),
|
|
506
|
+
input.progress ? json(progressPatch(input.progress)) : null,
|
|
507
|
+
input.progress ? clearedKeys(input.progress) : [],
|
|
508
|
+
]);
|
|
509
|
+
if (result.rowCount !== 1)
|
|
510
|
+
return false;
|
|
511
|
+
if (input.ledger?.void) {
|
|
512
|
+
await db.query(`UPDATE ${s}.ledger SET voided_at = $2
|
|
513
|
+
WHERE job_id = $1 AND voided_at IS NULL AND NOT (asset = ANY ($3::text[]))`, [id, input.now, [...(input.ledger.keepAssets ?? [])]]);
|
|
514
|
+
}
|
|
515
|
+
for (const entry of input.ledger?.record ?? []) {
|
|
516
|
+
await db.query(`INSERT INTO ${s}.ledger (job_id, wallet, deployment_id, asset, amount, recorded_at)
|
|
517
|
+
VALUES ($1, $2, $3, $4, $5::numeric, $6)`, [id, entry.wallet, entry.deploymentId, entry.asset, entry.amount, entry.recordedAt ?? input.now]);
|
|
518
|
+
}
|
|
519
|
+
return true;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
// Another job of the wallet is active.
|
|
524
|
+
if (code(error) === UNIQUE_VIOLATION)
|
|
525
|
+
return false;
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
async renew(id, worker, until) {
|
|
530
|
+
await ready();
|
|
531
|
+
const result = await executor.query(`UPDATE ${s}.jobs SET lease_until = $3 WHERE id = $1 AND lease_worker = $2`, [id, worker, until]);
|
|
532
|
+
return result.rowCount === 1;
|
|
533
|
+
},
|
|
534
|
+
async completeDelivery(id, worker, steps, now) {
|
|
535
|
+
await ready();
|
|
536
|
+
return atomic(async (db) => {
|
|
537
|
+
const done = await db.query(`UPDATE ${s}.jobs SET state = 'done', lease_worker = NULL, lease_until = NULL, updated_at = $3, outcome = NULL
|
|
538
|
+
WHERE id = $1 AND kind = 'delivery' AND state = 'running' AND lease_worker = $2`, [id, worker, now]);
|
|
539
|
+
if (done.rowCount !== 1)
|
|
540
|
+
return false;
|
|
541
|
+
for (const step of steps) {
|
|
542
|
+
await db.query(`INSERT INTO ${s}.jobs (id, kind, state, wallet, deployment_id, step, data, not_before, created_at, updated_at)
|
|
543
|
+
VALUES ($1, 'step', 'queued', $2, $3, $4, $5::jsonb, $6, $6, $6)
|
|
544
|
+
ON CONFLICT (id) DO NOTHING`, [
|
|
545
|
+
step.id,
|
|
546
|
+
step.request.wallet,
|
|
547
|
+
step.deploymentId,
|
|
548
|
+
step.request.step,
|
|
549
|
+
json({ deliveryId: id, deploymentId: step.deploymentId, request: step.request, progress: { generation: 0 } }),
|
|
550
|
+
now,
|
|
551
|
+
]);
|
|
552
|
+
}
|
|
553
|
+
return true;
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
async lastCompleted({ wallet, deploymentId, step }) {
|
|
557
|
+
await ready();
|
|
558
|
+
const { rows } = await executor.query(`SELECT max(updated_at) AS at FROM ${s}.jobs
|
|
559
|
+
WHERE kind = 'step' AND state = 'completed' AND wallet = $1 AND deployment_id = $2 AND step = $3`, [wallet, deploymentId, step]);
|
|
560
|
+
return numOrNull(rows[0]?.at);
|
|
561
|
+
},
|
|
562
|
+
async list(filter = {}) {
|
|
563
|
+
await ready();
|
|
564
|
+
const { rows } = await executor.query(`SELECT * FROM ${s}.jobs WHERE ($1::text IS NULL OR kind = $1) AND ($2::text IS NULL OR state = $2) ORDER BY seq`, [filter.kind ?? null, filter.state ?? null]);
|
|
565
|
+
return rows.map(rowToJob);
|
|
566
|
+
},
|
|
567
|
+
async prune(before) {
|
|
568
|
+
await ready();
|
|
569
|
+
const result = await executor.query(`DELETE FROM ${s}.jobs WHERE state IN ${FINISHED} AND updated_at < $1`, [before]);
|
|
570
|
+
return result.rowCount ?? 0;
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
consent: {
|
|
574
|
+
async put(grant) {
|
|
575
|
+
await ready();
|
|
576
|
+
await executor.query(`INSERT INTO ${s}.consents (wallet, deployment_id, instance_id, steps, expires_at, revoked_at, grant_json)
|
|
577
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
|
578
|
+
ON CONFLICT (wallet, deployment_id, instance_id) DO UPDATE SET
|
|
579
|
+
steps = excluded.steps, expires_at = excluded.expires_at,
|
|
580
|
+
revoked_at = excluded.revoked_at, grant_json = excluded.grant_json`, [
|
|
581
|
+
grant.wallet,
|
|
582
|
+
grant.deploymentId,
|
|
583
|
+
grant.instanceId ?? '',
|
|
584
|
+
[...grant.steps],
|
|
585
|
+
grant.expiresAt,
|
|
586
|
+
grant.revokedAt,
|
|
587
|
+
json(grant),
|
|
588
|
+
]);
|
|
589
|
+
},
|
|
590
|
+
async find({ wallet, deploymentId, step, instanceId, now }) {
|
|
591
|
+
await ready();
|
|
592
|
+
const { rows } = await executor.query(`SELECT * FROM ${s}.consents
|
|
593
|
+
WHERE wallet = $1 AND deployment_id = $2 AND (instance_id = '' OR instance_id = $3)
|
|
594
|
+
AND $4 = ANY (steps) AND revoked_at IS NULL AND expires_at > $5
|
|
595
|
+
ORDER BY instance_id <> '' DESC
|
|
596
|
+
LIMIT 1`, [wallet, deploymentId, instanceId ?? '', step, now]);
|
|
597
|
+
return rows[0] ? rowToGrant(rows[0]) : null;
|
|
598
|
+
},
|
|
599
|
+
async revoke({ wallet, deploymentId, steps, instanceId, now }) {
|
|
600
|
+
await ready();
|
|
601
|
+
return atomic(async (db) => {
|
|
602
|
+
const { rows } = await db.query(`SELECT * FROM ${s}.consents
|
|
603
|
+
WHERE wallet = $1 AND deployment_id = $2 AND revoked_at IS NULL AND ($3::text IS NULL OR instance_id = $3)
|
|
604
|
+
FOR UPDATE`, [wallet, deploymentId, instanceId === undefined ? null : instanceId]);
|
|
605
|
+
let changed = 0;
|
|
606
|
+
for (const row of rows) {
|
|
607
|
+
const current = row.steps;
|
|
608
|
+
if (steps && !current.some((step) => steps.includes(step)))
|
|
609
|
+
continue;
|
|
610
|
+
const remaining = steps ? current.filter((step) => !steps.includes(step)) : [];
|
|
611
|
+
const grant = { ...row.grant_json, steps: remaining.length > 0 ? remaining : current };
|
|
612
|
+
await db.query(`UPDATE ${s}.consents SET steps = $4, revoked_at = $5, grant_json = $6::jsonb
|
|
613
|
+
WHERE wallet = $1 AND deployment_id = $2 AND instance_id = $3`, [
|
|
614
|
+
row.wallet,
|
|
615
|
+
row.deployment_id,
|
|
616
|
+
row.instance_id,
|
|
617
|
+
remaining.length > 0 ? remaining : current,
|
|
618
|
+
remaining.length > 0 ? null : now,
|
|
619
|
+
json(grant),
|
|
620
|
+
]);
|
|
621
|
+
changed += 1;
|
|
622
|
+
}
|
|
623
|
+
return changed;
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
async list({ wallet }) {
|
|
627
|
+
await ready();
|
|
628
|
+
const { rows } = await executor.query(`SELECT * FROM ${s}.consents WHERE wallet = $1 ORDER BY deployment_id, instance_id`, [wallet]);
|
|
629
|
+
return rows.map(rowToGrant);
|
|
630
|
+
},
|
|
631
|
+
async active({ deploymentId, step, now }) {
|
|
632
|
+
await ready();
|
|
633
|
+
const { rows } = await executor.query(`SELECT * FROM ${s}.consents
|
|
634
|
+
WHERE deployment_id = $1 AND revoked_at IS NULL AND expires_at > $2 AND ($3::text IS NULL OR $3 = ANY (steps))
|
|
635
|
+
ORDER BY wallet, instance_id`, [deploymentId, now, step ?? null]);
|
|
636
|
+
return rows.map(rowToGrant);
|
|
637
|
+
},
|
|
638
|
+
async issueNonce(nonce, expiresAt) {
|
|
639
|
+
await ready();
|
|
640
|
+
await executor.query(`INSERT INTO ${s}.consent_nonces (nonce, expires_at) VALUES ($1, $2) ON CONFLICT (nonce) DO NOTHING`, [nonce, expiresAt]);
|
|
641
|
+
},
|
|
642
|
+
async useNonce(nonce, now) {
|
|
643
|
+
await ready();
|
|
644
|
+
const result = await executor.query(`UPDATE ${s}.consent_nonces SET used_at = $2 WHERE nonce = $1 AND used_at IS NULL AND expires_at > $2`, [nonce, now]);
|
|
645
|
+
return result.rowCount === 1;
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
sources: {
|
|
649
|
+
async get(source, key) {
|
|
650
|
+
await ready();
|
|
651
|
+
const { rows } = await executor.query(`SELECT * FROM ${s}.source_state WHERE source = $1 AND key = $2`, [source, key]);
|
|
652
|
+
return rows[0] ? rowToSource(rows[0]) : null;
|
|
653
|
+
},
|
|
654
|
+
async advance({ source, key, expectedVersion, next, deliver, now }) {
|
|
655
|
+
await ready();
|
|
656
|
+
try {
|
|
657
|
+
return await atomic(async (db) => {
|
|
658
|
+
let row;
|
|
659
|
+
if (expectedVersion === null) {
|
|
660
|
+
const inserted = await db.query(`INSERT INTO ${s}.source_state (source, key, hash, value, generation, pending, observed, version, updated_at)
|
|
661
|
+
VALUES ($1, $2, $3, $4::jsonb, 1, $5::jsonb, $7, 1, $6)
|
|
662
|
+
ON CONFLICT (source, key) DO NOTHING
|
|
663
|
+
RETURNING *`, [source, key, next.hash, json(next.value), next.pending ? json(next.pending) : null, now, next.observed ?? null]);
|
|
664
|
+
row = inserted.rows[0];
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
const updated = await db.query(`UPDATE ${s}.source_state SET
|
|
668
|
+
generation = generation + CASE WHEN hash = $3 THEN 0 ELSE 1 END,
|
|
669
|
+
hash = $3, value = $4::jsonb, pending = $5::jsonb, observed = $8, version = version + 1, updated_at = $6
|
|
670
|
+
WHERE source = $1 AND key = $2 AND version = $7
|
|
671
|
+
RETURNING *`, [source, key, next.hash, json(next.value), next.pending ? json(next.pending) : null, now, expectedVersion, next.observed ?? null]);
|
|
672
|
+
row = updated.rows[0];
|
|
673
|
+
}
|
|
674
|
+
if (!row)
|
|
675
|
+
return null;
|
|
676
|
+
const state = rowToSource(row);
|
|
677
|
+
if (deliver) {
|
|
678
|
+
await acceptIn(db, {
|
|
679
|
+
id: `view:${encodeURIComponent(source)}:${encodeURIComponent(key)}:${state.generation}`,
|
|
680
|
+
name: deliver.name,
|
|
681
|
+
source: 'view',
|
|
682
|
+
payload: deliver.payload,
|
|
683
|
+
receivedAt: now,
|
|
684
|
+
}, now);
|
|
685
|
+
}
|
|
686
|
+
return state;
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
if (code(error) === UNIQUE_VIOLATION)
|
|
691
|
+
return null;
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
async cursor(source) {
|
|
696
|
+
await ready();
|
|
697
|
+
const { rows } = await executor.query(`SELECT cursor FROM ${s}.source_cursors WHERE source = $1`, [source]);
|
|
698
|
+
return rows[0]?.cursor ?? null;
|
|
699
|
+
},
|
|
700
|
+
async putCursor(source, cursor, now) {
|
|
701
|
+
await ready();
|
|
702
|
+
const order = parseCursor(cursor);
|
|
703
|
+
if (!order)
|
|
704
|
+
throw new TypeError(`'${cursor}' is not an {epoch}:{offset} cursor`);
|
|
705
|
+
// Only forward within an epoch, atomically; a new epoch always moves on.
|
|
706
|
+
await executor.query(`INSERT INTO ${s}.source_cursors (source, cursor, updated_at) VALUES ($1, $2, $3)
|
|
707
|
+
ON CONFLICT (source) DO UPDATE SET cursor = excluded.cursor, updated_at = excluded.updated_at
|
|
708
|
+
WHERE ${s}.source_cursors.cursor !~ ':[0-9]+$'
|
|
709
|
+
OR regexp_replace(${s}.source_cursors.cursor, ':[0-9]+$', '') <> $4
|
|
710
|
+
OR substring(${s}.source_cursors.cursor from ':([0-9]+)$')::numeric < $5::numeric`, [source, cursor, now, order.epoch, order.offset.toString()]);
|
|
711
|
+
},
|
|
712
|
+
async acquireLease(source, holder, until, now) {
|
|
713
|
+
await ready();
|
|
714
|
+
const { rows } = await executor.query(`INSERT INTO ${s}.source_leases (source, holder, until) VALUES ($1, $2, $3)
|
|
715
|
+
ON CONFLICT (source) DO UPDATE SET holder = excluded.holder, until = excluded.until
|
|
716
|
+
WHERE ${s}.source_leases.holder = excluded.holder OR ${s}.source_leases.until <= $4
|
|
717
|
+
RETURNING holder`, [source, holder, until, now]);
|
|
718
|
+
return rows.length === 1;
|
|
719
|
+
},
|
|
720
|
+
async releaseLease(source, holder) {
|
|
721
|
+
await ready();
|
|
722
|
+
await executor.query(`DELETE FROM ${s}.source_leases WHERE source = $1 AND holder = $2`, [source, holder]);
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
ledger: {
|
|
726
|
+
async spent({ wallet, deploymentId, asset, since }) {
|
|
727
|
+
await ready();
|
|
728
|
+
const { rows } = await executor.query(`SELECT coalesce(sum(amount), 0)::text AS total FROM ${s}.ledger
|
|
729
|
+
WHERE wallet = $1 AND deployment_id = $2 AND asset = $3 AND voided_at IS NULL AND recorded_at >= $4`, [wallet, deploymentId, asset, since]);
|
|
730
|
+
return BigInt(rows[0]?.total ?? '0');
|
|
731
|
+
},
|
|
732
|
+
async list({ wallet }) {
|
|
733
|
+
await ready();
|
|
734
|
+
const { rows } = await executor.query(`SELECT * FROM ${s}.ledger WHERE wallet = $1 ORDER BY id`, [wallet]);
|
|
735
|
+
return rows.map((row) => ({
|
|
736
|
+
jobId: row.job_id,
|
|
737
|
+
wallet: row.wallet,
|
|
738
|
+
deploymentId: row.deployment_id,
|
|
739
|
+
asset: row.asset,
|
|
740
|
+
amount: String(row.amount),
|
|
741
|
+
recordedAt: num(row.recorded_at),
|
|
742
|
+
voidedAt: numOrNull(row.voided_at),
|
|
743
|
+
}));
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
};
|
|
747
|
+
return clientMode ? queuedOn(executor, store) : store;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
exports.DEFAULT_SCHEMA = DEFAULT_SCHEMA;
|
|
751
|
+
exports.LATEST_SCHEMA_VERSION = LATEST_SCHEMA_VERSION;
|
|
752
|
+
exports.MIGRATIONS = MIGRATIONS;
|
|
753
|
+
exports.SchemaVersionError = SchemaVersionError;
|
|
754
|
+
exports.migrationFiles = migrationFiles;
|
|
755
|
+
exports.postgresStore = postgresStore;
|
|
756
|
+
exports.runtimeRoleGrants = runtimeRoleGrants;
|
|
757
|
+
//# sourceMappingURL=postgres.cjs.map
|