@arcforgelabs/flow 2026.10.18 → 2026.10.19

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.
Files changed (65) hide show
  1. package/README.md +3 -0
  2. package/SECURITY.md +4 -1
  3. package/npm-shrinkwrap.json +32 -3
  4. package/package.json +3 -2
  5. package/server/app.js +26 -3
  6. package/server/auth/routes.js +27 -12
  7. package/server/auth/session.js +6 -3
  8. package/server/auth/settings.js +26 -7
  9. package/server/auth/sign-in.html +17 -6
  10. package/server/core/invoices.js +6 -3
  11. package/server/core/ledger.js +420 -0
  12. package/server/db/migrations/0003_billing_ledger.sql +84 -0
  13. package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
  14. package/server/db/migrations/meta/0003_snapshot.json +1327 -0
  15. package/server/db/migrations/meta/0004_snapshot.json +1327 -0
  16. package/server/db/migrations/meta/_journal.json +14 -0
  17. package/server/db/schema.js +137 -0
  18. package/server/import/earlier-flow.js +230 -0
  19. package/server/import-earlier-flow.js +66 -0
  20. package/server/index.js +16 -4
  21. package/server/integrations/adapters.js +6 -0
  22. package/server/integrations/health.js +21 -0
  23. package/server/integrations/index.js +26 -0
  24. package/server/integrations/port.js +154 -6
  25. package/server/integrations/remove-tables.js +31 -0
  26. package/server/integrations/schema.js +62 -0
  27. package/server/integrations/seazona/client.js +196 -0
  28. package/server/integrations/seazona/drizzle.config.js +9 -0
  29. package/server/integrations/seazona/feed.js +104 -0
  30. package/server/integrations/seazona/governor.js +174 -0
  31. package/server/integrations/seazona/index.js +84 -0
  32. package/server/integrations/seazona/lanes.js +431 -0
  33. package/server/integrations/seazona/ledger.js +66 -0
  34. package/server/integrations/seazona/map.js +229 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
  37. package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
  38. package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
  40. package/server/integrations/seazona/replay.js +79 -0
  41. package/server/integrations/seazona/scheduler.js +78 -0
  42. package/server/integrations/seazona/schema.js +157 -0
  43. package/server/integrations/seazona/settings.js +161 -0
  44. package/server/integrations/seazona/store.js +219 -0
  45. package/server/integrations/seazona/sync-once.js +44 -0
  46. package/server/integrations/seazona/tables.js +17 -0
  47. package/server/integrations/xero/catalogue.js +184 -0
  48. package/server/integrations/xero/client.js +253 -0
  49. package/server/integrations/xero/connections.js +104 -0
  50. package/server/integrations/xero/drafts.js +525 -0
  51. package/server/integrations/xero/fixtures/replay.json +45 -0
  52. package/server/integrations/xero/governor.js +275 -0
  53. package/server/integrations/xero/index.js +56 -0
  54. package/server/integrations/xero/ledger.js +269 -0
  55. package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
  56. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
  57. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
  58. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +34 -0
  60. package/server/integrations/xero/oauth.js +143 -0
  61. package/server/integrations/xero/replay.js +83 -0
  62. package/server/integrations/xero/routes.js +234 -0
  63. package/server/integrations/xero/runtime.js +135 -0
  64. package/server/integrations/xero/settings.js +170 -0
  65. 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
- return Object.freeze({ id, name, bundled: Boolean(bundled), capabilities: Object.freeze({ ...capabilities }) });
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 that declare one capability. The core fans out to
74
- // these and nothing else, so a disabled adapter changes nothing.
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
+ }