@arcforgelabs/flow 2026.10.18 → 2026.10.19-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/SECURITY.md +4 -1
- package/npm-shrinkwrap.json +32 -3
- package/package.json +3 -2
- package/server/app.js +26 -3
- package/server/auth/routes.js +27 -12
- package/server/auth/session.js +6 -3
- package/server/auth/settings.js +26 -7
- package/server/auth/sign-in.html +17 -6
- package/server/core/invoices.js +6 -3
- package/server/core/ledger.js +420 -0
- package/server/db/migrations/0003_billing_ledger.sql +84 -0
- package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
- package/server/db/migrations/meta/0003_snapshot.json +1327 -0
- package/server/db/migrations/meta/0004_snapshot.json +1327 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +137 -0
- package/server/import/earlier-flow.js +230 -0
- package/server/import-earlier-flow.js +66 -0
- package/server/index.js +16 -4
- package/server/integrations/adapters.js +6 -0
- package/server/integrations/health.js +21 -0
- package/server/integrations/index.js +26 -0
- package/server/integrations/port.js +154 -6
- package/server/integrations/remove-tables.js +31 -0
- package/server/integrations/schema.js +62 -0
- package/server/integrations/seazona/client.js +196 -0
- package/server/integrations/seazona/drizzle.config.js +9 -0
- package/server/integrations/seazona/feed.js +104 -0
- package/server/integrations/seazona/governor.js +174 -0
- package/server/integrations/seazona/index.js +84 -0
- package/server/integrations/seazona/lanes.js +431 -0
- package/server/integrations/seazona/ledger.js +66 -0
- package/server/integrations/seazona/map.js +229 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
- package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
- package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
- package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
- package/server/integrations/seazona/replay.js +79 -0
- package/server/integrations/seazona/scheduler.js +78 -0
- package/server/integrations/seazona/schema.js +157 -0
- package/server/integrations/seazona/settings.js +161 -0
- package/server/integrations/seazona/store.js +219 -0
- package/server/integrations/seazona/sync-once.js +44 -0
- package/server/integrations/seazona/tables.js +17 -0
- package/server/integrations/xero/catalogue.js +184 -0
- package/server/integrations/xero/client.js +253 -0
- package/server/integrations/xero/connections.js +104 -0
- package/server/integrations/xero/drafts.js +525 -0
- package/server/integrations/xero/fixtures/replay.json +45 -0
- package/server/integrations/xero/governor.js +275 -0
- package/server/integrations/xero/index.js +56 -0
- package/server/integrations/xero/ledger.js +269 -0
- package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
- package/server/integrations/xero/migrations/meta/_journal.json +34 -0
- package/server/integrations/xero/oauth.js +143 -0
- package/server/integrations/xero/replay.js +83 -0
- package/server/integrations/xero/routes.js +234 -0
- package/server/integrations/xero/runtime.js +135 -0
- package/server/integrations/xero/settings.js +170 -0
- package/server/rate-limit.js +13 -19
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
// adapter (Seazona, Xero, and later ones). The core calls only the
|
|
3
3
|
// capabilities an adapter declares, and never imports an adapter directly.
|
|
4
4
|
// VISION.md, "The core and its integrations".
|
|
5
|
+
//
|
|
6
|
+
// An adapter owns its tables. It declares them in `schema` with its own
|
|
7
|
+
// migrations, and Flow creates them only while the adapter is enabled
|
|
8
|
+
// (server/integrations/schema.js). docs/INTEGRATIONS.md has the rules.
|
|
9
|
+
|
|
10
|
+
import { isMissingTable, migrateAdapterSchema } from './schema.js';
|
|
5
11
|
|
|
6
12
|
export const CAPABILITIES = Object.freeze([
|
|
7
13
|
'pullWorkItems', // billable work since a cursor (Seazona: completed cases)
|
|
@@ -12,12 +18,50 @@ export const CAPABILITIES = Object.freeze([
|
|
|
12
18
|
]);
|
|
13
19
|
|
|
14
20
|
const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
|
|
21
|
+
const TABLE_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
|
|
22
|
+
|
|
23
|
+
// An adapter's tables start with its id (dashes become underscores), so they
|
|
24
|
+
// can never collide with the core's or another adapter's.
|
|
25
|
+
export function tablePrefix(id) {
|
|
26
|
+
return `${id.replaceAll('-', '_')}_`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defineSchema(id, schema) {
|
|
30
|
+
if (schema === undefined || schema === null) return null;
|
|
31
|
+
const { migrationsFolder, tables } = schema;
|
|
32
|
+
if (typeof migrationsFolder !== 'string' || migrationsFolder === '') {
|
|
33
|
+
throw new TypeError(`Adapter ${id} schema needs a migrationsFolder`);
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(tables) || tables.length === 0) {
|
|
36
|
+
throw new TypeError(`Adapter ${id} schema must list its tables`);
|
|
37
|
+
}
|
|
38
|
+
const prefix = tablePrefix(id);
|
|
39
|
+
for (const table of tables) {
|
|
40
|
+
if (typeof table !== 'string' || !TABLE_PATTERN.test(table) || !table.startsWith(prefix)) {
|
|
41
|
+
throw new TypeError(`Adapter ${id} table ${table} must be a lower-case name that starts with ${prefix}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return Object.freeze({ migrationsFolder, tables: Object.freeze([...new Set(tables)]) });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// What code gets when it asks for an adapter that is off, instead of an error
|
|
48
|
+
// from a table that does not exist. `reason` is `disabled` (turned off),
|
|
49
|
+
// `restart_needed` (turned on after this start: its tables come at the next
|
|
50
|
+
// start) or `tables_missing` (its tables were removed by hand).
|
|
51
|
+
export function turnedOff(adapter, reason = 'disabled') {
|
|
52
|
+
const error = {
|
|
53
|
+
disabled: `${adapter.name} is turned off on this installation.`,
|
|
54
|
+
restart_needed: `${adapter.name} was turned on after Flow started. Restart Flow to finish turning it on.`,
|
|
55
|
+
tables_missing: `${adapter.name} has no tables on this installation. Restart Flow to create them.`,
|
|
56
|
+
}[reason];
|
|
57
|
+
return { ok: false, code: 'integration_off', integration: adapter.id, reason, error };
|
|
58
|
+
}
|
|
15
59
|
|
|
16
60
|
export function defineAdapter(adapter) {
|
|
17
61
|
if (!adapter || typeof adapter !== 'object') {
|
|
18
62
|
throw new TypeError('An adapter must be an object');
|
|
19
63
|
}
|
|
20
|
-
const { id, name, bundled = false, capabilities } = adapter;
|
|
64
|
+
const { id, name, bundled = false, capabilities, schema, start, stop, routes } = adapter;
|
|
21
65
|
if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
|
|
22
66
|
throw new TypeError(`Adapter id must match ${ID_PATTERN}`);
|
|
23
67
|
}
|
|
@@ -38,12 +82,47 @@ export function defineAdapter(adapter) {
|
|
|
38
82
|
if (typeof capabilities.health !== 'function') {
|
|
39
83
|
throw new TypeError(`Adapter ${id} must declare health`);
|
|
40
84
|
}
|
|
41
|
-
|
|
85
|
+
for (const [hook, fn] of Object.entries({ start, stop })) {
|
|
86
|
+
if (fn !== undefined && typeof fn !== 'function') {
|
|
87
|
+
throw new TypeError(`Adapter ${id} ${hook} must be a function`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (routes !== undefined && typeof routes !== 'function') {
|
|
91
|
+
throw new TypeError(`Adapter ${id} routes must be a function that returns a router`);
|
|
92
|
+
}
|
|
93
|
+
return Object.freeze({
|
|
94
|
+
id,
|
|
95
|
+
name,
|
|
96
|
+
bundled: Boolean(bundled),
|
|
97
|
+
capabilities: Object.freeze({ ...capabilities }),
|
|
98
|
+
schema: defineSchema(id, schema),
|
|
99
|
+
// Optional lifecycle: start({ db, log }) runs once at server start, after
|
|
100
|
+
// the adapter's tables are in place, and only while it is enabled; it is
|
|
101
|
+
// where an adapter keeps its database handle and starts background work.
|
|
102
|
+
// stop() ends that work (tests, shutdown).
|
|
103
|
+
start: start ?? null,
|
|
104
|
+
stop: stop ?? null,
|
|
105
|
+
// Optional HTTP routes: routes({ db, personAuth, log }) returns a router
|
|
106
|
+
// mounted at /api/<id> (registry.routes).
|
|
107
|
+
routes: routes ?? null,
|
|
108
|
+
});
|
|
42
109
|
}
|
|
43
110
|
|
|
44
|
-
export function createRegistry() {
|
|
111
|
+
export function createRegistry({ log = console } = {}) {
|
|
45
112
|
const adapters = new Map();
|
|
46
113
|
const enabled = new Set();
|
|
114
|
+
// Adapters whose tables this process created or found at start. An adapter
|
|
115
|
+
// without a schema needs none, so it is ready as soon as it is enabled.
|
|
116
|
+
const tablesReady = new Set();
|
|
117
|
+
let started = false;
|
|
118
|
+
const running = []; // adapters whose start() ran, in start order
|
|
119
|
+
|
|
120
|
+
const state = (adapter) => {
|
|
121
|
+
if (!enabled.has(adapter.id)) return 'disabled';
|
|
122
|
+
if (!adapter.schema || tablesReady.has(adapter.id)) return 'ready';
|
|
123
|
+
return started ? 'restart_needed' : 'not_started';
|
|
124
|
+
};
|
|
125
|
+
const ready = (adapter) => state(adapter) === 'ready';
|
|
47
126
|
|
|
48
127
|
return {
|
|
49
128
|
register(adapter) {
|
|
@@ -58,27 +137,96 @@ export function createRegistry() {
|
|
|
58
137
|
if (!adapters.has(id)) throw new Error(`No adapter ${id}`);
|
|
59
138
|
enabled.add(id);
|
|
60
139
|
},
|
|
140
|
+
// Turning an adapter off never drops its tables. Removing them is a
|
|
141
|
+
// separate step (removeAdapterTables in server/integrations/schema.js).
|
|
61
142
|
disable(id) {
|
|
62
143
|
enabled.delete(id);
|
|
63
144
|
},
|
|
145
|
+
// Runs once at server start, after the core migrations: creates the
|
|
146
|
+
// tables of every enabled adapter and nothing else, then calls each
|
|
147
|
+
// enabled adapter's start({ db, log }). An adapter turned on later gets
|
|
148
|
+
// its tables at the next start. Returns the ids it migrated.
|
|
149
|
+
async start(db) {
|
|
150
|
+
if (started) throw new Error('The integration registry has already started');
|
|
151
|
+
started = true;
|
|
152
|
+
const migrated = [];
|
|
153
|
+
for (const id of enabled) {
|
|
154
|
+
const adapter = adapters.get(id);
|
|
155
|
+
if (!adapter.schema) continue;
|
|
156
|
+
await migrateAdapterSchema(db, adapter);
|
|
157
|
+
tablesReady.add(id);
|
|
158
|
+
migrated.push(id);
|
|
159
|
+
}
|
|
160
|
+
for (const id of enabled) {
|
|
161
|
+
const adapter = adapters.get(id);
|
|
162
|
+
if (!adapter.start) continue;
|
|
163
|
+
await adapter.start({ db, log });
|
|
164
|
+
running.push(adapter);
|
|
165
|
+
}
|
|
166
|
+
return migrated;
|
|
167
|
+
},
|
|
168
|
+
// Calls stop() on every adapter whose start() ran, newest first.
|
|
169
|
+
async stop() {
|
|
170
|
+
while (running.length) {
|
|
171
|
+
const adapter = running.pop();
|
|
172
|
+
await adapter.stop?.();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
64
175
|
list() {
|
|
65
176
|
return [...adapters.values()].map((a) => ({
|
|
66
177
|
id: a.id,
|
|
67
178
|
name: a.name,
|
|
68
179
|
bundled: a.bundled,
|
|
69
180
|
enabled: enabled.has(a.id),
|
|
181
|
+
state: state(a),
|
|
182
|
+
tables: a.schema ? [...a.schema.tables] : [],
|
|
70
183
|
capabilities: Object.keys(a.capabilities),
|
|
71
184
|
}));
|
|
72
185
|
},
|
|
73
|
-
// The enabled adapters
|
|
74
|
-
// these and nothing else, so a disabled
|
|
186
|
+
// The enabled adapters, with their tables in place, that declare one
|
|
187
|
+
// capability. The core fans out to these and nothing else, so a disabled
|
|
188
|
+
// adapter changes nothing.
|
|
75
189
|
with(capability) {
|
|
76
190
|
if (!CAPABILITIES.includes(capability)) {
|
|
77
191
|
throw new TypeError(`Unknown capability ${capability}`);
|
|
78
192
|
}
|
|
79
193
|
return [...enabled]
|
|
80
194
|
.map((id) => adapters.get(id))
|
|
81
|
-
.filter((a) => typeof a.capabilities[capability] === 'function');
|
|
195
|
+
.filter((a) => ready(a) && typeof a.capabilities[capability] === 'function');
|
|
196
|
+
},
|
|
197
|
+
// The adapters' own HTTP routes, each under /api/<id>. `context` is
|
|
198
|
+
// { db, personAuth, log }. An enabled, ready adapter's router is built
|
|
199
|
+
// once, here; a request to an adapter that is off gets its turned-off
|
|
200
|
+
// answer (503), so a turned-off adapter never reads its settings or
|
|
201
|
+
// tables.
|
|
202
|
+
routes(context) {
|
|
203
|
+
return [...adapters.values()].filter((a) => a.routes).map((adapter) => {
|
|
204
|
+
const path = `/api/${adapter.id}`;
|
|
205
|
+
if (!ready(adapter)) {
|
|
206
|
+
return { path, handler: (_req, res) => res.status(503).json(turnedOff(adapter, state(adapter) === 'disabled' ? 'disabled' : 'restart_needed')) };
|
|
207
|
+
}
|
|
208
|
+
return { path, handler: adapter.routes(context) };
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
// The one way to read or write an adapter's tables: runs fn(adapter) when
|
|
212
|
+
// the adapter is enabled and its tables are in place, and otherwise
|
|
213
|
+
// returns the adapter's turned-off answer. A table removed by hand gives
|
|
214
|
+
// the same answer rather than an error.
|
|
215
|
+
async whenEnabled(id, fn) {
|
|
216
|
+
const adapter = adapters.get(id);
|
|
217
|
+
if (!adapter) throw new Error(`No adapter ${id}`);
|
|
218
|
+
const now = state(adapter);
|
|
219
|
+
if (now === 'disabled') return turnedOff(adapter, 'disabled');
|
|
220
|
+
if (now !== 'ready') return turnedOff(adapter, 'restart_needed');
|
|
221
|
+
try {
|
|
222
|
+
return await fn(adapter);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (adapter.schema && isMissingTable(error)) {
|
|
225
|
+
log.error?.(`[integrations] ${id}: ${error.message}`);
|
|
226
|
+
return turnedOff(adapter, 'tables_missing');
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
82
230
|
},
|
|
83
231
|
};
|
|
84
232
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Removes a turned-off integration's tables: the separate, explicit step that
|
|
3
|
+
// turning an integration off never takes. It refuses while the integration is
|
|
4
|
+
// in FLOW_INTEGRATIONS and while any of its tables holds a row.
|
|
5
|
+
//
|
|
6
|
+
// DATABASE_URL=postgres://… node server/integrations/remove-tables.js <id>
|
|
7
|
+
import { openDatabase } from '../db/index.js';
|
|
8
|
+
import { adapters } from './adapters.js';
|
|
9
|
+
import { resolveEnabledIntegrations } from './index.js';
|
|
10
|
+
import { defineAdapter } from './port.js';
|
|
11
|
+
import { removeAdapterTables } from './schema.js';
|
|
12
|
+
|
|
13
|
+
const id = process.argv[2];
|
|
14
|
+
const found = adapters.find((a) => a.id === id);
|
|
15
|
+
if (!found) {
|
|
16
|
+
console.error(`Usage: remove-tables.js <id>. Known: ${adapters.map((a) => a.id).join(', ') || 'none'}.`);
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const database = openDatabase({ max: 1 });
|
|
21
|
+
try {
|
|
22
|
+
const adapter = defineAdapter(found);
|
|
23
|
+
const enabled = resolveEnabledIntegrations().includes(id);
|
|
24
|
+
const { removed } = await removeAdapterTables(database.db, adapter, { enabled });
|
|
25
|
+
console.log(removed.length ? `Removed ${removed.join(', ')}` : `${adapter.name} had no tables`);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(error.message);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
} finally {
|
|
30
|
+
await database.close();
|
|
31
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
|
3
|
+
|
|
4
|
+
// An adapter's tables: created from its own migrations while it is enabled,
|
|
5
|
+
// never dropped by turning it off. Each adapter keeps its own migration
|
|
6
|
+
// journal (drizzle.__drizzle_migrations_<id>), so the core's journal and
|
|
7
|
+
// every other adapter's stay independent. docs/INTEGRATIONS.md.
|
|
8
|
+
|
|
9
|
+
const MISSING_TABLE = '42P01'; // PostgreSQL undefined_table
|
|
10
|
+
|
|
11
|
+
export function migrationsTable(adapter) {
|
|
12
|
+
return `__drizzle_migrations_${adapter.id.replaceAll('-', '_')}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The adapter's declared tables that do not exist.
|
|
16
|
+
export async function missingAdapterTables(db, adapter) {
|
|
17
|
+
const missing = [];
|
|
18
|
+
for (const table of adapter.schema.tables) {
|
|
19
|
+
const { rows } = await db.execute(sql`select to_regclass(${`public.${table}`}) is null as missing`);
|
|
20
|
+
if (rows[0].missing) missing.push(table);
|
|
21
|
+
}
|
|
22
|
+
return missing;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Runs the adapter's pending migrations, then checks they created every table
|
|
26
|
+
// it declares, so the declaration stays true.
|
|
27
|
+
export async function migrateAdapterSchema(db, adapter) {
|
|
28
|
+
await migrate(db, { migrationsFolder: adapter.schema.migrationsFolder, migrationsTable: migrationsTable(adapter) });
|
|
29
|
+
const missing = await missingAdapterTables(db, adapter);
|
|
30
|
+
if (missing.length) {
|
|
31
|
+
throw new Error(`Adapter ${adapter.id} migrations did not create ${missing.join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isMissingTable(error) {
|
|
36
|
+
return error?.code === MISSING_TABLE || error?.cause?.code === MISSING_TABLE;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The separate, explicit step that removes a turned-off adapter's tables. It
|
|
40
|
+
// refuses while the adapter is enabled and while any of its tables holds a
|
|
41
|
+
// row, so it never removes data. Everything goes in one transaction.
|
|
42
|
+
export async function removeAdapterTables(db, adapter, { enabled }) {
|
|
43
|
+
if (!adapter.schema) return { removed: [] };
|
|
44
|
+
if (enabled) throw new Error(`Turn ${adapter.name} off before removing its tables`);
|
|
45
|
+
const missing = new Set(await missingAdapterTables(db, adapter));
|
|
46
|
+
const present = adapter.schema.tables.filter((table) => !missing.has(table));
|
|
47
|
+
return db.transaction(async (tx) => {
|
|
48
|
+
const holding = [];
|
|
49
|
+
for (const table of present) {
|
|
50
|
+
const { rows } = await tx.execute(sql`select exists (select 1 from ${sql.identifier(table)}) as holding`);
|
|
51
|
+
if (rows[0].holding) holding.push(table);
|
|
52
|
+
}
|
|
53
|
+
if (holding.length) {
|
|
54
|
+
throw new Error(`${adapter.name} tables still hold rows: ${holding.join(', ')}. Nothing was removed.`);
|
|
55
|
+
}
|
|
56
|
+
if (present.length) {
|
|
57
|
+
await tx.execute(sql`drop table ${sql.join(present.map((t) => sql.identifier(t)), sql`, `)}`);
|
|
58
|
+
}
|
|
59
|
+
await tx.execute(sql`drop table if exists ${sql.identifier('drizzle')}.${sql.identifier(migrationsTable(adapter))}`);
|
|
60
|
+
return { removed: present };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createSeazonaGovernor, normaliseRequestPath } from './governor.js';
|
|
3
|
+
import { createReplayFetch } from './replay.js';
|
|
4
|
+
|
|
5
|
+
// The Seazona REST client. It only reads: Flow's billing needs invoices,
|
|
6
|
+
// orders, clients, products and users, and none of that writes to Seazona.
|
|
7
|
+
//
|
|
8
|
+
// Every request goes through the rate governor (governor.js). In live and
|
|
9
|
+
// sandbox mode a client without a governor refuses to start, so no code path
|
|
10
|
+
// can call Seazona ungoverned. Replay mode answers from a file and never
|
|
11
|
+
// reaches a network.
|
|
12
|
+
|
|
13
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
16
|
+
|
|
17
|
+
// A failed Seazona request. The message names the method, the path and the
|
|
18
|
+
// status or failure kind; never the host, the query or a credential.
|
|
19
|
+
// kind: http, timeout, network, invalid_json, replay_miss, governor_timeout.
|
|
20
|
+
export class SeazonaApiError extends Error {
|
|
21
|
+
constructor(message, { statusCode = null, kind = 'http', path = null, retryAfterMs = null } = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'SeazonaApiError';
|
|
24
|
+
this.code = 'seazona_api_request_failed';
|
|
25
|
+
this.statusCode = statusCode;
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.path = path;
|
|
28
|
+
this.retryAfterMs = retryAfterMs;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseRetryAfterMs(value, nowMs = Date.now()) {
|
|
33
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
34
|
+
if (!text) return null;
|
|
35
|
+
const seconds = Number(text);
|
|
36
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000);
|
|
37
|
+
const at = Date.parse(text);
|
|
38
|
+
return Number.isFinite(at) ? Math.max(0, at - nowMs) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A stable, non-secret name for one API client, so processes that share the
|
|
42
|
+
// database share one rate limit without storing the client id.
|
|
43
|
+
export function deriveClientKey(settings) {
|
|
44
|
+
if (settings.mode === 'replay') return 'replay';
|
|
45
|
+
return createHash('sha256')
|
|
46
|
+
.update(`${settings.credentials?.clientId || ''}@${settings.baseUrl}`)
|
|
47
|
+
.digest('hex')
|
|
48
|
+
.slice(0, 24);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildUrl(baseUrl, path, query) {
|
|
52
|
+
const url = new URL(`${baseUrl.replace(/\/+$/, '')}/${String(path || '').replace(/^\/+/, '')}`);
|
|
53
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
54
|
+
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
|
|
55
|
+
}
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function describeFailure(error) {
|
|
60
|
+
if (error?.name === 'AbortError') return 'timeout';
|
|
61
|
+
if (error?.kind === 'replay_miss') return 'replay_miss';
|
|
62
|
+
return 'network';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createSeazonaClient({
|
|
66
|
+
settings,
|
|
67
|
+
db = null,
|
|
68
|
+
governor = null,
|
|
69
|
+
fetchImpl = null,
|
|
70
|
+
workflow = 'seazona',
|
|
71
|
+
now = Date.now,
|
|
72
|
+
sleepFn = sleep,
|
|
73
|
+
random = Math.random,
|
|
74
|
+
} = {}) {
|
|
75
|
+
if (!settings?.mode) throw new TypeError('The Seazona client needs a mode (SEAZONA_MODE)');
|
|
76
|
+
const { mode, baseUrl, credentials, rate } = settings;
|
|
77
|
+
|
|
78
|
+
let transport = fetchImpl;
|
|
79
|
+
if (mode === 'replay') {
|
|
80
|
+
// Replay never falls back to a real fetch.
|
|
81
|
+
transport ??= createReplayFetch(settings.replayFile);
|
|
82
|
+
} else {
|
|
83
|
+
transport ??= globalThis.fetch;
|
|
84
|
+
if (!credentials) throw new TypeError(`The Seazona client needs credentials in ${mode} mode`);
|
|
85
|
+
}
|
|
86
|
+
if (typeof transport !== 'function') throw new TypeError('A fetch implementation is required');
|
|
87
|
+
|
|
88
|
+
let gate = governor;
|
|
89
|
+
if (!gate && db) {
|
|
90
|
+
gate = createSeazonaGovernor({
|
|
91
|
+
db,
|
|
92
|
+
clientKey: deriveClientKey(settings),
|
|
93
|
+
requestsPerMinute: rate.requestsPerMinute,
|
|
94
|
+
maxWaitMs: rate.maxWaitMs,
|
|
95
|
+
telemetryRetentionMs: rate.telemetryRetentionMs,
|
|
96
|
+
now,
|
|
97
|
+
sleepFn,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!gate && mode !== 'replay') {
|
|
101
|
+
throw new TypeError(`The Seazona client needs the rate governor in ${mode} mode`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const authorization = credentials
|
|
105
|
+
? `Basic ${Buffer.from(`${credentials.clientId}:${credentials.clientSecret}`).toString('base64')}`
|
|
106
|
+
: null;
|
|
107
|
+
const stats = { requests: 0, retries: 0, rateLimited: 0 };
|
|
108
|
+
|
|
109
|
+
// One request at a time from this process; the governor spaces them out
|
|
110
|
+
// across processes.
|
|
111
|
+
let tail = Promise.resolve();
|
|
112
|
+
function serialise(work) {
|
|
113
|
+
const run = tail.catch(() => {}).then(work);
|
|
114
|
+
tail = run.catch(() => {});
|
|
115
|
+
return run;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function attempt(url, isRetry) {
|
|
119
|
+
const path = normaliseRequestPath(url.pathname);
|
|
120
|
+
const admission = gate ? await gate.reserve({ workflow, isRetry }) : null;
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => controller.abort(), rate.timeoutMs);
|
|
123
|
+
const startedAt = Number(now());
|
|
124
|
+
stats.requests += 1;
|
|
125
|
+
try {
|
|
126
|
+
const headers = { Accept: 'application/json' };
|
|
127
|
+
if (authorization) headers.Authorization = authorization;
|
|
128
|
+
const response = await transport(url, { method: 'GET', headers, signal: controller.signal });
|
|
129
|
+
const body = await response.text();
|
|
130
|
+
const retryAfterMs = parseRetryAfterMs(response.headers?.get?.('retry-after'), Number(now()));
|
|
131
|
+
if (admission) {
|
|
132
|
+
await gate.recordResponse({
|
|
133
|
+
requestId: admission.requestId,
|
|
134
|
+
path,
|
|
135
|
+
statusCode: response.status,
|
|
136
|
+
durationMs: Number(now()) - startedAt,
|
|
137
|
+
retryAfterMs: response.status === 429 && retryAfterMs !== null ? Math.min(retryAfterMs, MAX_RETRY_AFTER_MS) : null,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { status: response.status, ok: response.ok, body, retryAfterMs };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const kind = describeFailure(error);
|
|
143
|
+
if (admission) {
|
|
144
|
+
await gate.recordResponse({ requestId: admission.requestId, path, failureKind: kind, durationMs: Number(now()) - startedAt });
|
|
145
|
+
}
|
|
146
|
+
return { failure: kind, path };
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// GET a resource path relative to the API root. Returns the parsed JSON, or
|
|
153
|
+
// null for an empty body; the caller decides whether empty is a failure.
|
|
154
|
+
async function get(path, query = {}) {
|
|
155
|
+
const url = buildUrl(baseUrl, path, query);
|
|
156
|
+
const label = `GET ${url.pathname}`;
|
|
157
|
+
for (let tryNumber = 0; ; tryNumber += 1) {
|
|
158
|
+
const result = await serialise(() => attempt(url, tryNumber > 0));
|
|
159
|
+
if (result.failure) {
|
|
160
|
+
throw new SeazonaApiError(`Seazona ${label} failed (${result.failure})`, { kind: result.failure, path: url.pathname });
|
|
161
|
+
}
|
|
162
|
+
const retryable = result.status === 429 || result.status >= 500;
|
|
163
|
+
if (retryable && tryNumber < rate.maxRetries) {
|
|
164
|
+
if (result.status === 429) stats.rateLimited += 1;
|
|
165
|
+
stats.retries += 1;
|
|
166
|
+
const backoffMs = Math.min(MAX_RETRY_AFTER_MS, 1_000 * (2 ** tryNumber));
|
|
167
|
+
const delayMs = Math.min(MAX_RETRY_AFTER_MS, Math.max(result.retryAfterMs ?? 0, backoffMs) + Math.floor(random() * 250));
|
|
168
|
+
await sleepFn(delayMs);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
throw new SeazonaApiError(`Seazona ${label} failed with HTTP ${result.status}`, {
|
|
173
|
+
statusCode: result.status,
|
|
174
|
+
kind: 'http',
|
|
175
|
+
path: url.pathname,
|
|
176
|
+
retryAfterMs: result.retryAfterMs === null ? null : Math.min(result.retryAfterMs, MAX_RETRY_AFTER_MS),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (!result.body.trim()) return null;
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(result.body);
|
|
182
|
+
} catch {
|
|
183
|
+
throw new SeazonaApiError(`Seazona ${label} returned invalid JSON`, { kind: 'invalid_json', path: url.pathname });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
mode,
|
|
190
|
+
stats,
|
|
191
|
+
governor: gate,
|
|
192
|
+
get,
|
|
193
|
+
// A request count per run, for lane results.
|
|
194
|
+
requestCount: () => stats.requests,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineConfig } from 'drizzle-kit';
|
|
2
|
+
|
|
3
|
+
// The Seazona adapter's own migrations (docs/SEAZONA.md, "Tables"). Generate
|
|
4
|
+
// with: npx drizzle-kit generate --config server/integrations/seazona/drizzle.config.js
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
dialect: 'postgresql',
|
|
7
|
+
schema: './server/integrations/seazona/schema.js',
|
|
8
|
+
out: './server/integrations/seazona/migrations',
|
|
9
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { ledgerLinesFor, toLedgerHold, toLedgerJob } from './ledger.js';
|
|
3
|
+
import { orderStatusLabel, parseSeazonaDate } from './map.js';
|
|
4
|
+
import { seazonaClients, seazonaInvoices, seazonaOrders } from './schema.js';
|
|
5
|
+
|
|
6
|
+
// The billing feed behind the port's pullWorkItems: every order that changed
|
|
7
|
+
// since a cursor, oldest change first. A complete or shipped order is an
|
|
8
|
+
// item, with the Seazona invoice that carries it and its client. Any other
|
|
9
|
+
// changed order comes back in `withdrawn`, so invoice preparation can drop
|
|
10
|
+
// work it holds: an order deleted in Seazona is never an item (issue #12).
|
|
11
|
+
//
|
|
12
|
+
// The cursor is opaque; pass back `cursor` from the last page. A change to
|
|
13
|
+
// the order or to its invoice moves the order to the end of the feed.
|
|
14
|
+
|
|
15
|
+
const BILLABLE = /\b(complete|completed|shipped|delivered)\b/i;
|
|
16
|
+
const SHIPPED = /\b(shipped|delivered)\b/i;
|
|
17
|
+
|
|
18
|
+
function decodeCursor(cursor) {
|
|
19
|
+
if (!cursor) return null;
|
|
20
|
+
try {
|
|
21
|
+
const { at, id } = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
|
|
22
|
+
if (typeof id === 'string' && typeof at === 'string' && at) return { at, id };
|
|
23
|
+
} catch { /* fall through */ }
|
|
24
|
+
throw new TypeError('Not a Seazona feed cursor');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `at` is PostgreSQL's own text for the time, so no precision is lost.
|
|
28
|
+
function encodeCursor(at, id) {
|
|
29
|
+
return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
|
|
33
|
+
const after = decodeCursor(cursor);
|
|
34
|
+
const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}))`;
|
|
35
|
+
const pageSize = Math.max(1, Math.min(1000, limit));
|
|
36
|
+
const rows = await db.select({
|
|
37
|
+
order: seazonaOrders,
|
|
38
|
+
invoice: seazonaInvoices,
|
|
39
|
+
client: { accountNumber: seazonaClients.accountNumber, company: seazonaClients.company, email: seazonaClients.email },
|
|
40
|
+
// How many orders carry the same Seazona invoice (ledger.js, ledgerLinesFor).
|
|
41
|
+
invoiceOrderCount: sql`(select count(*)::int from ${seazonaOrders} as same where same.invoice_number = ${seazonaOrders.invoiceNumber} and same.deleted_in_source_at is null)`,
|
|
42
|
+
changedAt: sql`to_char(${changedAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
|
|
43
|
+
})
|
|
44
|
+
.from(seazonaOrders)
|
|
45
|
+
.leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
|
|
46
|
+
.leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
|
|
47
|
+
.where(after ? sql`(${changedAt}, ${seazonaOrders.id}) > (${after.at}::timestamptz, ${after.id})` : undefined)
|
|
48
|
+
.orderBy(changedAt, seazonaOrders.id)
|
|
49
|
+
.limit(pageSize);
|
|
50
|
+
|
|
51
|
+
const items = [];
|
|
52
|
+
const withdrawn = [];
|
|
53
|
+
for (const { order, invoice, client, invoiceOrderCount } of rows) {
|
|
54
|
+
const billable = !order.deletedInSourceAt && BILLABLE.test(order.status ?? '');
|
|
55
|
+
if (!billable) {
|
|
56
|
+
const entry = {
|
|
57
|
+
sourceId: order.id,
|
|
58
|
+
status: orderStatusLabel(order),
|
|
59
|
+
reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
|
|
60
|
+
};
|
|
61
|
+
withdrawn.push({ ...entry, ledgerHold: toLedgerHold(entry) });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const item = {
|
|
65
|
+
source: 'seazona',
|
|
66
|
+
sourceId: order.id,
|
|
67
|
+
invoiceNumber: order.invoiceNumber,
|
|
68
|
+
status: order.status,
|
|
69
|
+
shipped: SHIPPED.test(order.status ?? ''),
|
|
70
|
+
clientId: order.clientId,
|
|
71
|
+
company: order.company,
|
|
72
|
+
doctorName: order.doctorName,
|
|
73
|
+
orderedOn: parseSeazonaDate(order.orderedText),
|
|
74
|
+
dueOn: parseSeazonaDate(order.dueText),
|
|
75
|
+
lines: order.lines,
|
|
76
|
+
subtotalCents: order.subtotalCents,
|
|
77
|
+
patientReference: order.patientReference,
|
|
78
|
+
syncedAt: order.lastSyncedAt.toISOString(),
|
|
79
|
+
invoice: invoice ? {
|
|
80
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
81
|
+
status: invoice.status,
|
|
82
|
+
accountNumber: invoice.accountNumber,
|
|
83
|
+
invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
|
|
84
|
+
dueDate: parseSeazonaDate(invoice.dueText),
|
|
85
|
+
lines: invoice.lines,
|
|
86
|
+
subtotalCents: invoice.subtotalCents,
|
|
87
|
+
taxCents: invoice.taxCents,
|
|
88
|
+
totalCents: invoice.totalCents,
|
|
89
|
+
discountCents: invoice.discountCents,
|
|
90
|
+
patientReference: invoice.patientReference,
|
|
91
|
+
} : null,
|
|
92
|
+
client: client?.accountNumber || client?.company || client?.email ? client : null,
|
|
93
|
+
invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
|
|
94
|
+
};
|
|
95
|
+
items.push({ ...item, linesFrom: ledgerLinesFor(item).linesFrom, ledgerJob: toLedgerJob(item) });
|
|
96
|
+
}
|
|
97
|
+
const lastRow = rows.at(-1);
|
|
98
|
+
return {
|
|
99
|
+
items,
|
|
100
|
+
withdrawn,
|
|
101
|
+
cursor: lastRow ? encodeCursor(lastRow.changedAt, lastRow.order.id) : cursor,
|
|
102
|
+
more: rows.length === pageSize,
|
|
103
|
+
};
|
|
104
|
+
}
|