@arcforgelabs/flow 2026.10.30 → 2026.10.32-beta.1
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 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +75 -3
- package/server/core/price-list.js +348 -0
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +20 -1
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +37 -1
- package/server/integrations/seazona/lanes.js +186 -50
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -17
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { migrationsTable } from '../integrations/schema.js';
|
|
4
|
+
|
|
5
|
+
// Flow's doctor: checks a database against this build. It reads, and never
|
|
6
|
+
// changes anything.
|
|
7
|
+
//
|
|
8
|
+
// - each migration journal (Flow's own, and each shipped adapter's): how
|
|
9
|
+
// many of this build's migrations the database has applied;
|
|
10
|
+
// - every table and column this build's latest core snapshot declares, and
|
|
11
|
+
// every table each adapter declares: which are missing;
|
|
12
|
+
// - the tables in public that neither declares (another app's, or an
|
|
13
|
+
// earlier Flow's).
|
|
14
|
+
//
|
|
15
|
+
// On Replit it runs on the workspace's database after each update
|
|
16
|
+
// (scripts/replit-deploy.sh), and compares that database with the live app's
|
|
17
|
+
// GET /health, which reports the same migration counts. Replit's publish step
|
|
18
|
+
// offers to make production's tables match the workspace's, so the
|
|
19
|
+
// comparison says what that step will show (docs/REPLIT.md, "The workspace
|
|
20
|
+
// updater"). Anyone who runs Flow can run it: `flow-server doctor`.
|
|
21
|
+
|
|
22
|
+
const CORE_MIGRATIONS = fileURLToPath(new URL('./migrations', import.meta.url));
|
|
23
|
+
const CORE_TABLE = '__drizzle_migrations';
|
|
24
|
+
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What this build expects: one journal per migrations folder, and the tables
|
|
30
|
+
// (with their columns, where a snapshot names them) those migrations create.
|
|
31
|
+
export function expectedSchema({ adapters = [] } = {}) {
|
|
32
|
+
const journals = [{ id: 'core', table: CORE_TABLE, folder: CORE_MIGRATIONS }];
|
|
33
|
+
for (const adapter of adapters) {
|
|
34
|
+
if (!adapter.schema) continue;
|
|
35
|
+
journals.push({ id: adapter.id, table: migrationsTable(adapter), folder: adapter.schema.migrationsFolder, tables: adapter.schema.tables });
|
|
36
|
+
}
|
|
37
|
+
const tables = new Map();
|
|
38
|
+
for (const journal of journals) {
|
|
39
|
+
const { entries } = readJson(`${journal.folder}/meta/_journal.json`);
|
|
40
|
+
journal.tags = entries.map((entry) => entry.tag);
|
|
41
|
+
const last = entries.at(-1);
|
|
42
|
+
let snapshot = null;
|
|
43
|
+
try {
|
|
44
|
+
snapshot = last ? readJson(`${journal.folder}/meta/${String(last.idx).padStart(4, '0')}_snapshot.json`) : null;
|
|
45
|
+
} catch {
|
|
46
|
+
snapshot = null; // An adapter may ship its migrations without snapshots.
|
|
47
|
+
}
|
|
48
|
+
for (const table of Object.values(snapshot?.tables ?? {})) {
|
|
49
|
+
if ((table.schema || 'public') !== 'public') continue;
|
|
50
|
+
tables.set(table.name, new Set(Object.keys(table.columns)));
|
|
51
|
+
}
|
|
52
|
+
for (const name of journal.tables ?? []) {
|
|
53
|
+
if (!tables.has(name)) tables.set(name, null);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { journals, tables };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// How many migrations each journal has applied: { core: 14, xero: 6, ... }.
|
|
60
|
+
// A journal whose table does not exist (an adapter never turned on) counts 0.
|
|
61
|
+
export async function appliedMigrationCounts(pool, journals) {
|
|
62
|
+
const { rows } = await pool.query(
|
|
63
|
+
"select table_name from information_schema.tables where table_schema = 'drizzle'",
|
|
64
|
+
);
|
|
65
|
+
const present = new Set(rows.map((row) => row.table_name));
|
|
66
|
+
const counts = {};
|
|
67
|
+
for (const journal of journals) {
|
|
68
|
+
if (!present.has(journal.table)) {
|
|
69
|
+
counts[journal.id] = 0;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// The name was read from the catalogue above, so quoting it is enough.
|
|
73
|
+
const result = await pool.query(`select count(*)::int as n from drizzle."${journal.table.replaceAll('"', '""')}"`);
|
|
74
|
+
counts[journal.id] = result.rows[0].n;
|
|
75
|
+
}
|
|
76
|
+
return counts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function diagnoseDatabase(pool, { adapters = [] } = {}) {
|
|
80
|
+
const expected = expectedSchema({ adapters });
|
|
81
|
+
const applied = await appliedMigrationCounts(pool, expected.journals);
|
|
82
|
+
const pending = expected.journals
|
|
83
|
+
.filter((journal) => applied[journal.id] < journal.tags.length)
|
|
84
|
+
.map((journal) => ({ journal: journal.id, applied: applied[journal.id], shipped: journal.tags.length, next: journal.tags.slice(applied[journal.id]) }));
|
|
85
|
+
|
|
86
|
+
const { rows } = await pool.query(
|
|
87
|
+
"select table_name, column_name from information_schema.columns where table_schema = 'public'",
|
|
88
|
+
);
|
|
89
|
+
const actual = new Map();
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
if (!actual.has(row.table_name)) actual.set(row.table_name, new Set());
|
|
92
|
+
actual.get(row.table_name).add(row.column_name);
|
|
93
|
+
}
|
|
94
|
+
const missingTables = [];
|
|
95
|
+
const missingColumns = [];
|
|
96
|
+
for (const [table, columns] of expected.tables) {
|
|
97
|
+
const have = actual.get(table);
|
|
98
|
+
if (!have) {
|
|
99
|
+
missingTables.push(table);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const column of columns ?? []) {
|
|
103
|
+
if (!have.has(column)) missingColumns.push(`${table}.${column}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const unknownTables = [...actual.keys()].filter((table) => !expected.tables.has(table)).sort();
|
|
107
|
+
return {
|
|
108
|
+
ok: pending.length === 0 && missingTables.length === 0 && missingColumns.length === 0,
|
|
109
|
+
migrations: applied,
|
|
110
|
+
pending,
|
|
111
|
+
missingTables: missingTables.sort(),
|
|
112
|
+
missingColumns: missingColumns.sort(),
|
|
113
|
+
unknownTables,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What Replit's publish step will offer, from the workspace's report and the
|
|
118
|
+
// live app's migration counts (GET /health's schema.migrations, or null when
|
|
119
|
+
// the live app could not say).
|
|
120
|
+
//
|
|
121
|
+
// broken the workspace database is not at this build: do not publish
|
|
122
|
+
// behind production has migrations the workspace lacks: the list would
|
|
123
|
+
// drop them, so Cancel and update the workspace first
|
|
124
|
+
// adds the workspace is ahead: the list holds additions only; approve
|
|
125
|
+
// same the two match: no list
|
|
126
|
+
// unknown the live app did not say: approve additions, Cancel a DROP
|
|
127
|
+
export function publishVerdict(report, live) {
|
|
128
|
+
if (!report.ok) return { verdict: 'broken', say: 'The workspace database is not at this build. Do not publish. Update the workspace again.' };
|
|
129
|
+
if (!live) return { verdict: 'unknown', say: 'The live app did not report its migrations. Publish: approve a list of additions; Cancel any DROP.' };
|
|
130
|
+
const behind = Object.entries(live).filter(([id, n]) => n > (report.migrations[id] ?? 0)).map(([id]) => id);
|
|
131
|
+
if (behind.length) {
|
|
132
|
+
return { verdict: 'behind', say: `Production has ${behind.join(', ')} migrations the workspace lacks. Do not publish: Replit would offer to DROP them. Update the workspace first.` };
|
|
133
|
+
}
|
|
134
|
+
const ahead = Object.entries(report.migrations).filter(([id, n]) => n > (live[id] ?? 0)).map(([id]) => id);
|
|
135
|
+
if (ahead.length) {
|
|
136
|
+
return { verdict: 'adds', say: `The workspace is ahead of production (${ahead.join(', ')}). Publish: Replit offers to add tables or columns only. Approve.` };
|
|
137
|
+
}
|
|
138
|
+
return { verdict: 'same', say: 'The workspace and production are at the same migrations. Publish: Replit shows no database changes.' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The live app's migration counts from its GET /health, or null.
|
|
142
|
+
export async function liveMigrationCounts(origin, { fetchImpl = fetch, timeoutMs = 8000 } = {}) {
|
|
143
|
+
try {
|
|
144
|
+
const url = new URL('/health', origin);
|
|
145
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
146
|
+
if (!res.ok && res.status !== 503) return null;
|
|
147
|
+
const body = await res.json();
|
|
148
|
+
const counts = body?.schema?.migrations;
|
|
149
|
+
return counts && typeof counts === 'object' ? counts : null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function formatReport(report, verdict = null) {
|
|
156
|
+
const lines = [];
|
|
157
|
+
for (const [id, n] of Object.entries(report.migrations)) {
|
|
158
|
+
const pending = report.pending.find((p) => p.journal === id);
|
|
159
|
+
lines.push(`${id} migrations: ${n} applied${pending ? `, ${pending.next.length} pending (${pending.next.join(', ')})` : ''}`);
|
|
160
|
+
}
|
|
161
|
+
lines.push(report.missingTables.length ? `missing tables: ${report.missingTables.join(', ')}` : 'tables: all present');
|
|
162
|
+
lines.push(report.missingColumns.length ? `missing columns: ${report.missingColumns.join(', ')}` : 'columns: all present');
|
|
163
|
+
if (report.unknownTables.length) {
|
|
164
|
+
lines.push(`tables this build does not know: ${report.unknownTables.join(', ')}. On Replit, publishing offers to copy them to production.`);
|
|
165
|
+
}
|
|
166
|
+
if (verdict) lines.push(`verdict: ${verdict.verdict}. ${verdict.say}`);
|
|
167
|
+
return lines;
|
|
168
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "price_item_links" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"organisation_id" uuid NOT NULL,
|
|
4
|
+
"price_item_id" uuid NOT NULL,
|
|
5
|
+
"system" text NOT NULL,
|
|
6
|
+
"external_id" text NOT NULL,
|
|
7
|
+
"external_code" text,
|
|
8
|
+
"matched_by" text NOT NULL,
|
|
9
|
+
"created_by" uuid,
|
|
10
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
11
|
+
CONSTRAINT "price_item_links_matched_by_valid" CHECK ("price_item_links"."matched_by" in ('code', 'person')),
|
|
12
|
+
CONSTRAINT "price_item_links_system_valid" CHECK ("price_item_links"."system" ~ '^[a-z][a-z0-9-]{1,31}$')
|
|
13
|
+
);
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "account_code" text;--> statement-breakpoint
|
|
16
|
+
ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "updated_by" uuid;--> statement-breakpoint
|
|
17
|
+
ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
|
|
18
|
+
DO $flow_guard$ BEGIN
|
|
19
|
+
ALTER TABLE "price_item_links" ADD CONSTRAINT "price_item_links_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
|
|
20
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
21
|
+
END $flow_guard$;--> statement-breakpoint
|
|
22
|
+
DO $flow_guard$ BEGIN
|
|
23
|
+
ALTER TABLE "price_item_links" ADD CONSTRAINT "price_item_links_created_by_people_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
|
|
24
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
25
|
+
END $flow_guard$;--> statement-breakpoint
|
|
26
|
+
DO $flow_guard$ BEGIN
|
|
27
|
+
ALTER TABLE "price_item_links" ADD CONSTRAINT "price_item_links_item_same_org" FOREIGN KEY ("organisation_id","price_item_id") REFERENCES "public"."price_items"("organisation_id","id") ON DELETE cascade ON UPDATE no action;
|
|
28
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
29
|
+
END $flow_guard$;--> statement-breakpoint
|
|
30
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "price_item_links_external" ON "price_item_links" USING btree ("organisation_id","system","external_id");--> statement-breakpoint
|
|
31
|
+
DO $flow_guard$ BEGIN
|
|
32
|
+
ALTER TABLE "price_items" ADD CONSTRAINT "price_items_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
|
|
33
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
34
|
+
END $flow_guard$;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "alert_dismissals" (
|
|
2
|
+
"organisation_id" uuid NOT NULL,
|
|
3
|
+
"person_id" uuid NOT NULL,
|
|
4
|
+
"alert_key" text NOT NULL,
|
|
5
|
+
"members" text[] DEFAULT '{}'::text[] NOT NULL,
|
|
6
|
+
"dismissed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
7
|
+
CONSTRAINT "alert_dismissals_organisation_id_person_id_alert_key_pk" PRIMARY KEY("organisation_id","person_id","alert_key")
|
|
8
|
+
);
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
DO $flow_guard$ BEGIN
|
|
11
|
+
ALTER TABLE "alert_dismissals" ADD CONSTRAINT "alert_dismissals_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
|
|
12
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
13
|
+
END $flow_guard$;--> statement-breakpoint
|
|
14
|
+
DO $flow_guard$ BEGIN
|
|
15
|
+
ALTER TABLE "alert_dismissals" ADD CONSTRAINT "alert_dismissals_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "public"."people"("id") ON DELETE cascade ON UPDATE no action;
|
|
16
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
17
|
+
END $flow_guard$;
|