@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.
- package/README.md +2 -1
- package/VISION.md +40 -9
- package/npm-shrinkwrap.json +2 -414
- package/package.json +6 -1
- package/server/core/links.js +153 -0
- package/server/core/outbox.js +129 -0
- package/server/core/queue.js +142 -0
- package/server/core/review.js +92 -0
- package/server/db/migrations/0005_data_spine.sql +99 -0
- package/server/db/migrations/meta/0005_snapshot.json +2145 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +130 -0
- package/server/ui/routes.js +9 -2
package/server/db/schema.js
CHANGED
|
@@ -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
|
+
]);
|
package/server/ui/routes.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|