@arcforgelabs/flow 2026.10.20-beta.1 → 2026.10.22

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.
@@ -36,6 +36,13 @@
36
36
  "when": 1792329649575,
37
37
  "tag": "0004_ledger_events_append_only",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1792388648997,
44
+ "tag": "0005_data_spine",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
@@ -1,15 +1,18 @@
1
1
  import { sql } from 'drizzle-orm';
2
2
  import {
3
+ bigint,
3
4
  boolean,
4
5
  check,
5
6
  date,
6
7
  foreignKey,
8
+ index,
7
9
  integer,
8
10
  jsonb,
9
11
  pgTable,
10
12
  text,
11
13
  timestamp,
12
14
  unique,
15
+ uniqueIndex,
13
16
  uuid,
14
17
  } from 'drizzle-orm/pg-core';
15
18
 
@@ -304,3 +307,130 @@ export const obligationWritebacks = pgTable('obligation_writebacks', {
304
307
  }),
305
308
  check('obligation_writebacks_state_valid', inList('state', WRITEBACK_STATES)),
306
309
  ]);
310
+
311
+ // The data spine (docs/DATA_SPINE.md, issue 56): the tables every integration
312
+ // shares. Each row changes state; the spine never deletes one.
313
+
314
+ // Identity map (issue 57). Which external record is each Flow record. At most
315
+ // one active link per external record and per Flow record, in each system. A
316
+ // link that is not exact stays `proposed` until a person confirms it.
317
+ export const LINK_ENTITIES = ['contact', 'price_item', 'obligation', 'invoice', 'payment', 'credit_note'];
318
+ export const LINK_MATCHES = ['created_by_flow', 'exact_id', 'imported', 'name', 'person'];
319
+ export const LINK_STATES = ['active', 'proposed', 'superseded'];
320
+
321
+ export const externalLinks = pgTable('external_links', {
322
+ id: id(),
323
+ organisationId: organisationId().references(() => organisations.id),
324
+ entity: text('entity').notNull(),
325
+ flowId: uuid('flow_id').notNull(),
326
+ system: text('system').notNull(),
327
+ externalId: text('external_id').notNull(),
328
+ matchedBy: text('matched_by').notNull(),
329
+ state: text('state').notNull(),
330
+ confirmedBy: uuid('confirmed_by').references(() => people.id, { onDelete: 'set null' }),
331
+ confirmedAt: timestamp('confirmed_at', { withTimezone: true }),
332
+ createdAt: createdAt(),
333
+ updatedAt: updatedAt(),
334
+ }, (t) => [
335
+ unique('external_links_org_id').on(t.organisationId, t.id),
336
+ uniqueIndex('external_links_active_external')
337
+ .on(t.organisationId, t.system, t.entity, t.externalId).where(sql`state = 'active'`),
338
+ uniqueIndex('external_links_active_flow')
339
+ .on(t.organisationId, t.system, t.entity, t.flowId).where(sql`state = 'active'`),
340
+ index('external_links_flow').on(t.organisationId, t.entity, t.flowId),
341
+ check('external_links_entity_valid', inList('entity', LINK_ENTITIES)),
342
+ check('external_links_matched_by_valid', inList('matched_by', LINK_MATCHES)),
343
+ check('external_links_state_valid', inList('state', LINK_STATES)),
344
+ check('external_links_name_needs_person', sql`${t.matchedBy} <> 'name' or ${t.state} <> 'active' or ${t.confirmedAt} is not null`),
345
+ ]);
346
+
347
+ // Background job queue (issue 6). Workers claim with FOR UPDATE SKIP LOCKED and
348
+ // hold a lease; a lease that runs out makes the job claimable again. One queued
349
+ // or running job per (organisation, kind, dedupe key).
350
+ export const BACKGROUND_JOB_STATES = ['queued', 'running', 'succeeded', 'failed', 'dead'];
351
+
352
+ export const backgroundJobs = pgTable('background_jobs', {
353
+ id: id(),
354
+ organisationId: organisationId().references(() => organisations.id),
355
+ kind: text('kind').notNull(),
356
+ dedupeKey: text('dedupe_key').notNull().default(''),
357
+ payload: jsonb('payload').notNull().default(sql`'{}'::jsonb`),
358
+ state: text('state').notNull().default('queued'),
359
+ runAfter: timestamp('run_after', { withTimezone: true }).notNull().defaultNow(),
360
+ attempts: integer('attempts').notNull().default(0),
361
+ maxAttempts: integer('max_attempts').notNull().default(8),
362
+ lockedBy: text('locked_by'),
363
+ lockedUntil: timestamp('locked_until', { withTimezone: true }),
364
+ result: jsonb('result'),
365
+ lastError: text('last_error'),
366
+ createdAt: createdAt(),
367
+ updatedAt: updatedAt(),
368
+ }, (t) => [
369
+ uniqueIndex('background_jobs_one_open')
370
+ .on(t.organisationId, t.kind, t.dedupeKey).where(sql`state in ('queued', 'running')`),
371
+ index('background_jobs_due').on(t.state, t.runAfter),
372
+ check('background_jobs_state_valid', inList('state', BACKGROUND_JOB_STATES)),
373
+ check('background_jobs_attempts_valid', sql`${t.attempts} >= 0 and ${t.maxAttempts} > 0`),
374
+ ]);
375
+
376
+ // Outbox. Every write to another system is a row here before the call
377
+ // leaves, written in the same transaction as the Flow change it reports. The
378
+ // idempotency key is unique, so a retry never makes a second record.
379
+ export const OUTBOX_STATES = ['pending', 'sending', 'ambiguous', 'sent', 'failed', 'cancelled'];
380
+
381
+ export const outbox = pgTable('outbox', {
382
+ id: id(),
383
+ organisationId: organisationId().references(() => organisations.id),
384
+ system: text('system').notNull(),
385
+ action: text('action').notNull(),
386
+ entity: text('entity').notNull(),
387
+ flowId: uuid('flow_id').notNull(),
388
+ idempotencyKey: text('idempotency_key').notNull(),
389
+ request: jsonb('request').notNull(),
390
+ state: text('state').notNull().default('pending'),
391
+ externalId: text('external_id'),
392
+ attempts: integer('attempts').notNull().default(0),
393
+ lastError: text('last_error'),
394
+ createdBy: text('created_by').notNull(),
395
+ sentAt: timestamp('sent_at', { withTimezone: true }),
396
+ createdAt: createdAt(),
397
+ updatedAt: updatedAt(),
398
+ }, (t) => [
399
+ unique('outbox_org_id').on(t.organisationId, t.id),
400
+ unique('outbox_idempotency_key').on(t.idempotencyKey),
401
+ index('outbox_pending').on(t.system, t.state, t.createdAt),
402
+ check('outbox_state_valid', inList('state', OUTBOX_STATES)),
403
+ check('outbox_sent_has_id', sql`${t.state} <> 'sent' or ${t.externalId} is not null`),
404
+ ]);
405
+
406
+ // Review queue (issue 59). One list of everything a person must decide. One
407
+ // open item per (organisation, kind, entity, Flow id); a repeat finding
408
+ // updates its evidence.
409
+ export const REVIEW_STATES = ['open', 'resolved', 'dismissed'];
410
+
411
+ export const reviewItems = pgTable('review_items', {
412
+ id: id(),
413
+ organisationId: organisationId().references(() => organisations.id),
414
+ kind: text('kind').notNull(),
415
+ entity: text('entity').notNull(),
416
+ flowId: uuid('flow_id').notNull(),
417
+ systems: text('systems').array().notNull().default(sql`'{}'::text[]`),
418
+ evidence: jsonb('evidence').notNull().default(sql`'{}'::jsonb`),
419
+ moneyAtRiskCents: bigint('money_at_risk_cents', { mode: 'number' }).notNull().default(0),
420
+ state: text('state').notNull().default('open'),
421
+ seenCount: integer('seen_count').notNull().default(1),
422
+ resolution: text('resolution'),
423
+ note: text('note'),
424
+ resolvedBy: text('resolved_by'),
425
+ resolvedAt: timestamp('resolved_at', { withTimezone: true }),
426
+ createdAt: createdAt(),
427
+ updatedAt: updatedAt(),
428
+ }, (t) => [
429
+ unique('review_items_org_id').on(t.organisationId, t.id),
430
+ uniqueIndex('review_items_one_open')
431
+ .on(t.organisationId, t.kind, t.entity, t.flowId).where(sql`state = 'open'`),
432
+ index('review_items_queue').on(t.organisationId, t.state, t.moneyAtRiskCents),
433
+ check('review_items_state_valid', inList('state', REVIEW_STATES)),
434
+ check('review_items_money_not_negative', sql`${t.moneyAtRiskCents} >= 0`),
435
+ check('review_items_closed_has_resolution', sql`${t.state} = 'open' or (${t.resolution} is not null and ${t.resolvedBy} is not null and ${t.resolvedAt} is not null)`),
436
+ ]);
@@ -0,0 +1,52 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { organisations } from '../db/schema.js';
3
+ import { applyImport, planImport, readEarlierFlow, summarisePlan } from './earlier-flow.js';
4
+
5
+ // The import from the earlier codebase, run once by the server itself. On a
6
+ // host with no shell next to the app (a published Replit app) the operator
7
+ // moves the earlier tables into their own schema, then sets
8
+ // FLOW_IMPORT_EARLIER_SCHEMA and FLOW_IMPORT_ORGANISATION. While the database
9
+ // has no organisation, the next start reads that schema in a read-only
10
+ // transaction and writes the records (server/import/earlier-flow.js). Once an
11
+ // organisation exists, it does nothing.
12
+
13
+ const SCHEMA_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
14
+
15
+ export function resolveImportAtStart(env = process.env) {
16
+ const schema = (env.FLOW_IMPORT_EARLIER_SCHEMA ?? '').trim();
17
+ if (!schema) return null;
18
+ if (!SCHEMA_NAME.test(schema)) throw new Error('FLOW_IMPORT_EARLIER_SCHEMA must be a plain lower-case schema name, e.g. earlier');
19
+ const organisationName = (env.FLOW_IMPORT_ORGANISATION ?? '').trim();
20
+ if (!organisationName) throw new Error('FLOW_IMPORT_ORGANISATION (the lab\'s name) is required with FLOW_IMPORT_EARLIER_SCHEMA');
21
+ return { schema, organisationName };
22
+ }
23
+
24
+ // Returns the import's counts, or null when there was nothing to do.
25
+ export async function importEarlierAtStart({ db, pool, settings, log = console }) {
26
+ if (!settings) return null;
27
+ const [{ count }] = (await db.execute(sql`select count(*)::int as count from ${organisations}`)).rows;
28
+ if (count > 0) {
29
+ log.info?.('[import] skipped: this database already has an organisation (unset FLOW_IMPORT_EARLIER_SCHEMA)');
30
+ return null;
31
+ }
32
+
33
+ let plan;
34
+ const client = await pool.connect();
35
+ try {
36
+ await client.query('BEGIN TRANSACTION READ ONLY');
37
+ // The schema name was checked against SCHEMA_NAME, so it is safe to quote.
38
+ await client.query(`SET LOCAL search_path TO "${settings.schema}"`);
39
+ plan = planImport(await readEarlierFlow(client));
40
+ await client.query('COMMIT');
41
+ } catch (error) {
42
+ await client.query('ROLLBACK').catch(() => {});
43
+ throw new Error(`could not read schema "${settings.schema}": ${error.message}`);
44
+ } finally {
45
+ client.release();
46
+ }
47
+
48
+ log.info?.(`[import] plan from schema "${settings.schema}": ${JSON.stringify(summarisePlan(plan))}`);
49
+ const result = await applyImport(db, plan, { organisationName: settings.organisationName });
50
+ log.info?.(`[import] imported: ${JSON.stringify(result.counts)}`);
51
+ return result.counts;
52
+ }
@@ -45,6 +45,12 @@ function parseJson(text) {
45
45
  }
46
46
  }
47
47
 
48
+ // The earlier codebase stored some accounting payloads as the API sent them
49
+ // (`UnitPrice`) and some as its SDK returned them (`unitPrice`).
50
+ function field(object, name) {
51
+ return object[name] ?? object[name[0].toLowerCase() + name.slice(1)];
52
+ }
53
+
48
54
  function isTrue(value) {
49
55
  return value === true || value === 1 || value === '1' || value === 'true';
50
56
  }
@@ -82,8 +88,8 @@ function planContacts(rows, skipped) {
82
88
  skipped.push({ table: 'contacts', key, reason: 'unreadable_raw_json' });
83
89
  continue;
84
90
  }
85
- const customer = isTrue(raw.IsCustomer);
86
- const supplier = isTrue(raw.IsSupplier);
91
+ const customer = isTrue(field(raw, 'IsCustomer'));
92
+ const supplier = isTrue(field(raw, 'IsSupplier'));
87
93
  // A contact the accounting system has never billed or paid is most often
88
94
  // a new customer, so it comes across as one.
89
95
  const kinds = supplier && !customer ? ['supplier'] : supplier ? ['customer', 'supplier'] : ['customer'];
@@ -120,12 +126,12 @@ function planPriceItems(rows, skipped, defaulted) {
120
126
  skipped.push({ table: 'price_items', key, reason: 'unreadable_sales_details' });
121
127
  continue;
122
128
  }
123
- const unitAmountCents = toCents(sales.UnitPrice);
129
+ const unitAmountCents = toCents(field(sales, 'UnitPrice'));
124
130
  if (unitAmountCents === null) {
125
131
  skipped.push({ table: 'price_items', key, reason: 'no_sales_price' });
126
132
  continue;
127
133
  }
128
- const taxType = String(sales.TaxType ?? '').trim().toUpperCase();
134
+ const taxType = String(field(sales, 'TaxType') ?? '').trim().toUpperCase();
129
135
  if (!taxType) defaulted.push({ table: 'price_items', key, reason: 'no_tax_type_used_gst' });
130
136
  const taxCode = !taxType || GST_TAX_TYPES.has(taxType) ? 'GST' : 'GST_FREE';
131
137
  planned.push({ sourceKey: key, code, name, unitAmountCents, taxCode });
package/server/index.js CHANGED
@@ -5,6 +5,7 @@ import { seedFirstOwner } from './auth/people.js';
5
5
  import { createPersonAuth } from './auth/session.js';
6
6
  import { resolveSignInSettings } from './auth/settings.js';
7
7
  import { migrateDatabase, openDatabase } from './db/index.js';
8
+ import { importEarlierAtStart, resolveImportAtStart } from './import/at-start.js';
8
9
  import { createIntegrations } from './integrations/index.js';
9
10
  import { resolveDefaultFamily } from './theme/themes.js';
10
11
  import { createUpdateChecker, UPDATE_CHECK_INTERVAL_MS } from './update-check.js';
@@ -34,11 +35,26 @@ try {
34
35
  process.exit(1);
35
36
  }
36
37
 
38
+ let importAtStart;
39
+ try {
40
+ importAtStart = resolveImportAtStart();
41
+ } catch (error) {
42
+ console.error(`[import] ${error.message}`);
43
+ process.exit(1);
44
+ }
45
+
37
46
  // Flow's records live in PostgreSQL. Migrations run before the server accepts
38
47
  // requests, so a release never serves an older schema. Then each enabled
39
48
  // integration's own tables, and no others (docs/INTEGRATIONS.md).
40
49
  const database = openDatabase();
41
50
  await migrateDatabase(database.db);
51
+ // A failed import stops the start, so Flow never opens with half its records.
52
+ try {
53
+ await importEarlierAtStart({ db: database.db, pool: database.pool, settings: importAtStart });
54
+ } catch (error) {
55
+ console.error(`[import] ${error.message}`);
56
+ process.exit(1);
57
+ }
42
58
  await integrations.start(database.db);
43
59
  const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => a.id);
44
60
  console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'} (FLOW_INTEGRATIONS)`);
@@ -21,6 +21,13 @@ const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palette
21
21
  const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'flow-mark.svg']);
22
22
  const MAX_AGE = '1h';
23
23
 
24
+ // Send one named file from dir. Passing dir as root limits the dotfile check
25
+ // to the file name, so an install under a dot directory (such as
26
+ // .flow-deploy/) still serves its assets. A missing file falls through to the
27
+ // app's own 404.
28
+ const sendNamed = (res, next, dir, file) =>
29
+ res.sendFile(file, { root: dir, maxAge: MAX_AGE }, (err) => err && !res.headersSent && next());
30
+
24
31
  export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
25
32
  const router = express.Router();
26
33
  const css = themeStylesheet();
@@ -33,14 +40,14 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
33
40
 
34
41
  const sendFrom = (dir, allowed) => (req, res, next) => {
35
42
  if (!allowed.has(req.params.file)) return next();
36
- res.sendFile(join(dir, req.params.file), { maxAge: MAX_AGE });
43
+ sendNamed(res, next, dir, req.params.file);
37
44
  };
38
45
  router.get('/assets/theme/:file', sendFrom(themeDir, THEME_FILES));
39
46
  router.get('/assets/ui/:file', sendFrom(here, UI_FILES));
40
47
 
41
48
  for (const name of FONT_PACKAGES) {
42
49
  const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
43
- router.get(`/assets/fonts/${name}/index.css`, (_req, res) => res.sendFile(join(dir, 'index.css'), { maxAge: MAX_AGE }));
50
+ router.get(`/assets/fonts/${name}/index.css`, (_req, res, next) => sendNamed(res, next, dir, 'index.css'));
44
51
  router.use(`/assets/fonts/${name}/files`, express.static(join(dir, 'files'), { maxAge: MAX_AGE, index: false, fallthrough: false }));
45
52
  }
46
53