@arcforgelabs/flow 2026.10.21 → 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
+ ]);
@@ -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