@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.
- package/README.md +2 -1
- package/SECURITY.md +6 -0
- package/VISION.md +49 -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/import/at-start.js +52 -0
- package/server/import/earlier-flow.js +10 -4
- package/server/index.js +16 -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
|
+
]);
|
|
@@ -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
|
|
86
|
-
const supplier = isTrue(raw
|
|
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
|
|
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
|
|
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)`);
|
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
|
|