@xeplr/workflow 1.0.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +352 -0
  3. package/bin/www +7 -0
  4. package/db/xcfgSetup.js +8 -0
  5. package/env.required.js +41 -0
  6. package/index.js +313 -0
  7. package/lib/actionCatalog.js +193 -0
  8. package/lib/actions/jobRun.js +149 -0
  9. package/lib/actions/screenShow.js +55 -0
  10. package/lib/db.js +82 -0
  11. package/lib/envExposed.js +87 -0
  12. package/lib/flows.js +832 -0
  13. package/lib/flowsRouter.js +113 -0
  14. package/lib/router.js +260 -0
  15. package/lib/workflowRunner.js +649 -0
  16. package/migrations/0001_companies.sql +23 -0
  17. package/migrations/0002_workspaces.sql +22 -0
  18. package/migrations/0003_workflows.sql +35 -0
  19. package/migrations/0004_workflow_steps.sql +79 -0
  20. package/migrations/0005_workflow_runs.sql +49 -0
  21. package/migrations/0006_workflow_step_runs.sql +42 -0
  22. package/migrations/0007_workflow_resume_keys.sql +42 -0
  23. package/migrations/0008_workflow_run_edges.sql +43 -0
  24. package/migrations/0009_workflow_steps_layout.sql +11 -0
  25. package/migrations/0010_workflow_steps_sample_output.sql +17 -0
  26. package/migrations/0011_workflow_steps_params.sql +27 -0
  27. package/migrations/0012_workflows_kind.sql +27 -0
  28. package/migrations/0013_workflows_key.sql +48 -0
  29. package/migrations-auth/0001_workflow_access.sql +129 -0
  30. package/migrations-auth/0003_nav_menus.sql +62 -0
  31. package/migrations-auth/0004_flows_access.sql +76 -0
  32. package/models/Company.js +82 -0
  33. package/models/Workflow.js +63 -0
  34. package/models/WorkflowResumeKey.js +32 -0
  35. package/models/WorkflowRun.js +64 -0
  36. package/models/WorkflowRunEdge.js +49 -0
  37. package/models/WorkflowStep.js +66 -0
  38. package/models/WorkflowStepRun.js +49 -0
  39. package/models/Workspace.js +75 -0
  40. package/models/index.js +25 -0
  41. package/orchestration/standalone.js +123 -0
  42. package/package.json +69 -0
@@ -0,0 +1,22 @@
1
+ -- 0002_workspaces.sql
2
+ -- workspaces — the second tenancy level, under a company (mtId2 on every row
3
+ -- that lives inside one). Where a team's workflows are organised.
4
+
5
+ CREATE TABLE "workspaces" (
6
+ "id" varchar(25) PRIMARY KEY,
7
+ "companyId" varchar(25) NOT NULL,
8
+ "name" varchar(255) NOT NULL,
9
+ "description" varchar(500),
10
+ "tags" jsonb,
11
+ "isActive" boolean DEFAULT true,
12
+ "mtId1" varchar(25),
13
+ "mtId2" varchar(25),
14
+ "mtId3" varchar(25),
15
+ "mtId4" varchar(25),
16
+ "recordCreatedDate" timestamp,
17
+ "recordModifiedDate" timestamp,
18
+ "recordCreatedBy" varchar(25),
19
+ "recordModifiedBy" varchar(25)
20
+ );
21
+
22
+ CREATE INDEX "workspaces_company_index" ON "workspaces" ("companyId");
@@ -0,0 +1,35 @@
1
+ -- 0003_workflows.sql
2
+ -- workflows — the product's one document. A workflow is an ordered list of
3
+ -- steps, and a step names a registered @xeplr/actions action plus the input to
4
+ -- call it with. Binding actions together is the whole of what this app does;
5
+ -- everything else exists to make that list savable, runnable and auditable.
6
+ --
7
+ -- `params` are the workflow's own declared inputs — what a caller supplies at
8
+ -- run time (a file path, a customer id). Steps reference them as `{params.x}`
9
+ -- in a bound value, so the same workflow runs against different subjects
10
+ -- without being edited.
11
+
12
+ CREATE TABLE "workflows" (
13
+ "id" varchar(25) PRIMARY KEY,
14
+ "name" varchar(255) NOT NULL,
15
+ "description" varchar(1000),
16
+ -- Declared inputs: [{ name, type, required, default, description, order }] —
17
+ -- the same shape @xeplr/actions uses for an action's inputSchema, so a
18
+ -- workflow reads as an action made of actions.
19
+ "params" jsonb,
20
+ -- Draft workflows are editable and not runnable; published ones are what a
21
+ -- trigger is allowed to start. One column rather than a separate table
22
+ -- because it is a property of the workflow, not a thing of its own.
23
+ "status" varchar(20) DEFAULT 'draft',
24
+ "isActive" boolean DEFAULT true,
25
+ "mtId1" varchar(25),
26
+ "mtId2" varchar(25),
27
+ "mtId3" varchar(25),
28
+ "mtId4" varchar(25),
29
+ "recordCreatedDate" timestamp,
30
+ "recordModifiedDate" timestamp,
31
+ "recordCreatedBy" varchar(25),
32
+ "recordModifiedBy" varchar(25)
33
+ );
34
+
35
+ CREATE INDEX "workflows_status_index" ON "workflows" ("status");
@@ -0,0 +1,79 @@
1
+ -- 0004_workflow_steps.sql
2
+ -- workflow_steps — one call to one registered action, plus what happens after
3
+ -- it. `actionName` is a REFERENCE INTO THE @xeplr/actions REGISTRY, not a copy
4
+ -- of the action: no code, no endpoint, no credentials live here. A step whose
5
+ -- action the registry does not have cannot run, which is reported rather than
6
+ -- guessed at.
7
+ --
8
+ -- `values` is the argument object, and its string values may carry
9
+ -- placeholders — `{previous_step.output.x}`, `{steps.<key>.output.x}`,
10
+ -- `{params.x}`, and for a `kind = 'wait'` step, `{resumeKey}` — resolved by
11
+ -- @xeplr/schema-handler's templating against the run's context before the
12
+ -- action is called.
13
+ --
14
+ -- `kind`: 'auto' runs and advances the moment it finishes; 'wait' pauses the
15
+ -- run after starting — the command it calls is responsible for eventually
16
+ -- resolving the pause via workflowRunner.resumeByKey(key, output). See
17
+ -- 0007_workflow_resume_keys.sql for how that key is tracked.
18
+ --
19
+ -- `transitions` is an ordered array of
20
+ -- { condition, mode: 'single' | 'each', target }
21
+ -- evaluated top to bottom — first match wins. `condition` is a structured
22
+ -- @xeplr/expression-handler expression ({ left, op, right }) or null/absent,
23
+ -- which always matches (the catch-all row). `target` is another step's
24
+ -- `stepKey`, or the sentinel 'end_success' / 'end_failed'. A step with no
25
+ -- transitions at all keeps the old default: advance to the next step by
26
+ -- `position`. A step WITH transitions that finds no match (and has no
27
+ -- catch-all row) fails — silently falling through would hide a branch nobody
28
+ -- accounted for.
29
+ --
30
+ -- A transition marked 'each' fans the rest of that branch out once per
31
+ -- matching element instead of running it once — see workflow_run_edges for
32
+ -- how the resulting child runs relate back to this one. `joinStep`, if set,
33
+ -- names a step to run a single time once every one of those children has
34
+ -- finished (e.g. batch-move everything that was downloaded, instead of once
35
+ -- per item); only meaningful when at least one transition here is 'each'.
36
+ --
37
+ -- `onError` is unrelated to transitions — it governs what happens when the
38
+ -- action call itself throws (bad input, unreachable endpoint), not how a
39
+ -- successful result is routed: 'stop' (default) halts the run, 'continue'
40
+ -- lets the rest of the steps proceed regardless.
41
+ --
42
+ -- `stepKey` is how another step's transition or a later step's bound value
43
+ -- addresses this one. Author-facing and stable across reordering, which
44
+ -- `position` is not — insert a step in the middle and every position below it
45
+ -- changes, so a reference by position would silently start reading a
46
+ -- different step's output.
47
+
48
+ CREATE TABLE "workflow_steps" (
49
+ "id" varchar(25) PRIMARY KEY,
50
+ "workflowId" varchar(25) NOT NULL,
51
+ "stepKey" varchar(64) NOT NULL,
52
+ "name" varchar(255),
53
+ "actionName" varchar(128) NOT NULL,
54
+ "values" jsonb,
55
+ -- auto · wait
56
+ "kind" varchar(20) NOT NULL DEFAULT 'auto',
57
+ -- Only meaningful for kind = 'wait'. Milliseconds; null means no expiry.
58
+ "timeoutMs" integer,
59
+ -- stop · continue — what an execution failure of THIS step means for the
60
+ -- rest of the run.
61
+ "onError" varchar(20) DEFAULT 'stop',
62
+ "transitions" jsonb,
63
+ "joinStep" varchar(64),
64
+ "position" integer DEFAULT 0,
65
+ "isActive" boolean DEFAULT true,
66
+ "mtId1" varchar(25),
67
+ "mtId2" varchar(25),
68
+ "mtId3" varchar(25),
69
+ "mtId4" varchar(25),
70
+ "recordCreatedDate" timestamp,
71
+ "recordModifiedDate" timestamp,
72
+ "recordCreatedBy" varchar(25),
73
+ "recordModifiedBy" varchar(25)
74
+ );
75
+
76
+ CREATE INDEX "workflow_steps_workflow_index" ON "workflow_steps" ("workflowId");
77
+ -- Two steps with one key would make a `steps.<key>.output` reference (and a
78
+ -- transition `target`) ambiguous — resolving to whichever the reader picks.
79
+ CREATE UNIQUE INDEX "workflow_steps_key_unique" ON "workflow_steps" ("workflowId", "stepKey");
@@ -0,0 +1,49 @@
1
+ -- 0005_workflow_runs.sql
2
+ -- workflow_runs — one occurrence of one workflow: the thread that ties a
3
+ -- send-and-wait email, a UI form render, and everything that resumes them
4
+ -- back together. Persisted rather than kept in memory, because "what did this
5
+ -- do last night" is the question a workflow product exists to answer, and a
6
+ -- run that only lives in a process is a run nobody can look at after a
7
+ -- restart or a crash mid-wait.
8
+ --
9
+ -- `params` records the values the run was STARTED WITH, so a result can be
10
+ -- reproduced — re-resolving them later would read today's defaults over
11
+ -- yesterday's run.
12
+ --
13
+ -- `item` is set only on a CHILD run — the one element (out of however many an
14
+ -- 'each' transition matched) this particular occurrence exists to process.
15
+ -- Denormalized here for the runner's own hot-path reads; workflow_run_edges
16
+ -- is still the source of truth for the parent/child relationship and its
17
+ -- provenance (which step fanned out, which position this was).
18
+ --
19
+ -- `status`: queued · running · waiting · success · failed. 'waiting' is
20
+ -- distinct from 'running' — it means the run is paused on a wait step with a
21
+ -- live entry in workflow_resume_keys, not actively doing anything.
22
+
23
+ CREATE TABLE "workflow_runs" (
24
+ "id" varchar(25) PRIMARY KEY,
25
+ "workflowId" varchar(25) NOT NULL,
26
+ "status" varchar(20) NOT NULL DEFAULT 'queued',
27
+ "params" jsonb,
28
+ "item" jsonb,
29
+ -- Who or what asked: 'manual', or the name of whatever triggers it later.
30
+ "trigger" varchar(64) DEFAULT 'manual',
31
+ "startedAt" timestamp,
32
+ "finishedAt" timestamp,
33
+ "durationMs" integer,
34
+ -- The failure that ended the run, if one did. Per-step detail lives in
35
+ -- workflow_step_runs; this is the summary a list needs without a join.
36
+ "error" jsonb,
37
+ "isActive" boolean DEFAULT true,
38
+ "mtId1" varchar(25),
39
+ "mtId2" varchar(25),
40
+ "mtId3" varchar(25),
41
+ "mtId4" varchar(25),
42
+ "recordCreatedDate" timestamp,
43
+ "recordModifiedDate" timestamp,
44
+ "recordCreatedBy" varchar(25),
45
+ "recordModifiedBy" varchar(25)
46
+ );
47
+
48
+ CREATE INDEX "workflow_runs_workflow_index" ON "workflow_runs" ("workflowId");
49
+ CREATE INDEX "workflow_runs_status_index" ON "workflow_runs" ("status");
@@ -0,0 +1,42 @@
1
+ -- 0006_workflow_step_runs.sql
2
+ -- workflow_step_runs — what each step actually did while a run happened.
3
+ --
4
+ -- `stepKey` / `actionName` are COPIED from the step, not joined to it. A step
5
+ -- can be renamed, re-pointed at another action, or deleted after this ran;
6
+ -- the history has to keep saying what actually happened, which a join would
7
+ -- quietly rewrite out from under it.
8
+ --
9
+ -- `input` is the values object AFTER placeholders were resolved — what the
10
+ -- action was really called with, the only version worth keeping when a run
11
+ -- has gone wrong for reasons the template explains.
12
+ --
13
+ -- `status`: pending · running · waiting · success · failed · skipped.
14
+ -- 'waiting' mirrors the run's own status while this step holds a live
15
+ -- workflow_resume_keys row. 'skipped' is for a step a transition jumped over
16
+ -- — it never ran, and saying so plainly is the point of listing it as a
17
+ -- distinct status rather than leaving it absent.
18
+
19
+ CREATE TABLE "workflow_step_runs" (
20
+ "id" varchar(25) PRIMARY KEY,
21
+ "runId" varchar(25) NOT NULL,
22
+ "stepId" varchar(25),
23
+ "stepKey" varchar(64),
24
+ "actionName" varchar(128),
25
+ "status" varchar(20) NOT NULL DEFAULT 'pending',
26
+ "input" jsonb,
27
+ "output" jsonb,
28
+ "error" jsonb,
29
+ "durationMs" integer,
30
+ "position" integer DEFAULT 0,
31
+ "isActive" boolean DEFAULT true,
32
+ "mtId1" varchar(25),
33
+ "mtId2" varchar(25),
34
+ "mtId3" varchar(25),
35
+ "mtId4" varchar(25),
36
+ "recordCreatedDate" timestamp,
37
+ "recordModifiedDate" timestamp,
38
+ "recordCreatedBy" varchar(25),
39
+ "recordModifiedBy" varchar(25)
40
+ );
41
+
42
+ CREATE INDEX "workflow_step_runs_run_index" ON "workflow_step_runs" ("runId");
@@ -0,0 +1,42 @@
1
+ -- 0007_workflow_resume_keys.sql
2
+ -- workflow_resume_keys — the whole mechanism behind a 'wait' step, in one
3
+ -- table. When a wait step starts, the engine mints a random opaque `key`
4
+ -- here (NOT encrypted, NOT signed — just a securely random, one-time id, same
5
+ -- as every other row's `id` in this app) and hands it to the step's own
6
+ -- values as `{resumeKey}`, so the command can embed it wherever its channel
7
+ -- needs it: a confirmation link, a hidden field, an approval URL.
8
+ --
9
+ -- The consumer-side contract is exactly one call:
10
+ -- workflowRunner.resumeByKey(key, output)
11
+ -- It never needs to know which run or step the key belongs to — that lookup
12
+ -- lives entirely in this table, keyed by `key` alone. This is what lets
13
+ -- fan-out (see workflow_run_edges) work with zero changes to this mechanism:
14
+ -- once each matching item is its own child run, each one only ever has ONE
15
+ -- live pending key at a time, so there is never a collision to resolve.
16
+ --
17
+ -- `consumedDate` is set the moment resumeByKey succeeds OR the run advances
18
+ -- past this step by any other path (a transition routing elsewhere, the step
19
+ -- timing out) — a consumed or superseded key must not resolve a second call,
20
+ -- whether that call is a genuine retry or a stale/replayed link (an email
21
+ -- security scanner pre-fetching a confirmation URL is the case this is
22
+ -- actually for).
23
+
24
+ CREATE TABLE "workflow_resume_keys" (
25
+ "id" varchar(25) PRIMARY KEY,
26
+ "runId" varchar(25) NOT NULL,
27
+ "stepId" varchar(25) NOT NULL,
28
+ "key" varchar(64) NOT NULL,
29
+ "consumedDate" timestamp,
30
+ "isActive" boolean DEFAULT true,
31
+ "mtId1" varchar(25),
32
+ "mtId2" varchar(25),
33
+ "mtId3" varchar(25),
34
+ "mtId4" varchar(25),
35
+ "recordCreatedDate" timestamp,
36
+ "recordModifiedDate" timestamp,
37
+ "recordCreatedBy" varchar(25),
38
+ "recordModifiedBy" varchar(25)
39
+ );
40
+
41
+ CREATE UNIQUE INDEX "workflow_resume_keys_key_unique" ON "workflow_resume_keys" ("key");
42
+ CREATE INDEX "workflow_resume_keys_run_index" ON "workflow_resume_keys" ("runId");
@@ -0,0 +1,43 @@
1
+ -- 0008_workflow_run_edges.sql
2
+ -- workflow_run_edges — the ONLY place a workflow run's family tree lives.
3
+ -- workflow_steps stays flat by design (fan-out is a run-time decision, never
4
+ -- an authoring-time one — see the 'each' transition mode in
5
+ -- 0004_workflow_steps.sql), so nothing about a run's ancestry belongs on the
6
+ -- step definition. This table is the edge itself: one row per parent → child
7
+ -- run relationship, created once when an 'each' transition matches more than
8
+ -- zero elements.
9
+ --
10
+ -- `sourceStepKey` names which step's transition did the fanning out, so a
11
+ -- parent's timeline can say "this child came from check_unread_mail" without
12
+ -- guessing. `itemIndex` / `itemKey` position the child within that fan-out —
13
+ -- `itemKey` is a stable identifier from the item itself when one exists (an
14
+ -- email's message-id, say) rather than just an index, so a retry or a re-run
15
+ -- can still tell which item is which. `item` is the same value the child run
16
+ -- also carries on its own `item` column — kept here too because this table,
17
+ -- not the run, is what a parent's UI would actually join through to list
18
+ -- "everything this step fanned out into."
19
+ --
20
+ -- One child never has two parents: `childRunId` is unique. A parent can have
21
+ -- many children, which is the whole point.
22
+
23
+ CREATE TABLE "workflow_run_edges" (
24
+ "id" varchar(25) PRIMARY KEY,
25
+ "parentRunId" varchar(25) NOT NULL,
26
+ "childRunId" varchar(25) NOT NULL,
27
+ "sourceStepKey" varchar(64),
28
+ "itemIndex" integer,
29
+ "itemKey" varchar(255),
30
+ "item" jsonb,
31
+ "isActive" boolean DEFAULT true,
32
+ "mtId1" varchar(25),
33
+ "mtId2" varchar(25),
34
+ "mtId3" varchar(25),
35
+ "mtId4" varchar(25),
36
+ "recordCreatedDate" timestamp,
37
+ "recordModifiedDate" timestamp,
38
+ "recordCreatedBy" varchar(25),
39
+ "recordModifiedBy" varchar(25)
40
+ );
41
+
42
+ CREATE INDEX "workflow_run_edges_parent_index" ON "workflow_run_edges" ("parentRunId");
43
+ CREATE UNIQUE INDEX "workflow_run_edges_child_unique" ON "workflow_run_edges" ("childRunId");
@@ -0,0 +1,11 @@
1
+ -- 0009_workflow_steps_layout.sql
2
+ -- Canvas position is NOT execution order. `position` (0004) is an integer
3
+ -- used to sequence steps when a step has no transitions of its own — insert
4
+ -- one in the middle and every position below it shifts. Where a step sits on
5
+ -- the builder's free-form canvas is a separate, purely visual concern: two
6
+ -- steps can be dragged anywhere relative to each other without changing what
7
+ -- runs after what. `layout` is { x, y } in canvas pixels; null until a step
8
+ -- has been placed by hand, at which point the builder can lay new steps out
9
+ -- automatically (e.g. left to right by `position`).
10
+
11
+ ALTER TABLE "workflow_steps" ADD COLUMN "layout" jsonb;
@@ -0,0 +1,17 @@
1
+ -- 0010_workflow_steps_sample_output.sql
2
+ -- What this step is EXPECTED to return, as an example — never what it did
3
+ -- return. Purely a builder concern, like `layout` (0009), and never read by
4
+ -- workflowRunner: a real run's output lives on workflow_step_runs.
5
+ --
6
+ -- It exists because binding one step to another is otherwise done from memory.
7
+ -- A later step's value binds to {steps.<key>.output.something} and a
8
+ -- transition tests output.something, and until now nothing in the product
9
+ -- could tell you what `something` is for a given action — you either ran the
10
+ -- workflow and read the run record, or you guessed. Recording a sample lets
11
+ -- the step editor offer the real field names in a picker, which is also what
12
+ -- makes a mistyped binding catchable at build time rather than at run time.
13
+ --
14
+ -- Nullable, and null simply means the picker has nothing to offer for that
15
+ -- step yet — every existing step keeps working untouched.
16
+
17
+ ALTER TABLE "workflow_steps" ADD COLUMN "sampleOutput" jsonb;
@@ -0,0 +1,27 @@
1
+ -- 0011_workflow_steps_params.sql
2
+ -- `params` — what this step needs the CALLER to supply when the run is
3
+ -- started. Declared here, on the step, because the step is where you find out
4
+ -- you need it: you are binding `{params.customerEmail}` into an email-send's
5
+ -- `to` and there is no such parameter yet.
6
+ --
7
+ -- The workflow's run-start schema is the UNION of every step's declaration
8
+ -- (see workflowRunner's collectParams), not a separate list somebody has to
9
+ -- keep in step with these. That is the whole point of declaring it here —
10
+ -- adding the requirement and adding the binding are one action, and deleting
11
+ -- the step deletes the requirement with it rather than leaving a parameter
12
+ -- the run still demands and nothing reads.
13
+ --
14
+ -- Same field shape as `workflows.params` and as an action's inputSchema:
15
+ -- [{ name, type, required, default, description, order, sample? }]
16
+ -- so the engine validates a run's params with the SAME @xeplr/schema-handler
17
+ -- applySchema that validates a step's input against its action. One definition
18
+ -- of what "required" means, at both levels of the product.
19
+ --
20
+ -- `sample` is the one addition, and it is never sent anywhere: it is what the
21
+ -- builder's { } picker shows beside the name, so a list of parameters reads as
22
+ -- something you can recognise rather than something you must already know.
23
+ --
24
+ -- Nullable, and null means "this step asks nothing of the caller" — which is
25
+ -- every step that existed before this column did.
26
+
27
+ ALTER TABLE "workflow_steps" ADD COLUMN IF NOT EXISTS "params" jsonb;
@@ -0,0 +1,27 @@
1
+ -- 0012_workflows_kind.sql
2
+ -- WHAT A WORKFLOW'S STEPS ARE MADE OF — which is a question about the BUILDER,
3
+ -- not about the engine.
4
+ --
5
+ -- Connecting jobs to each other opens the same canvas, saves the same rows and
6
+ -- is driven by the same engine. The only difference is what the palette offers
7
+ -- and what dropping something onto the canvas produces:
8
+ --
9
+ -- 'workflow' the action catalogue; a drop names an action
10
+ -- 'jobs' the job list; a drop becomes a `job-run` step whose
11
+ -- values carry that job's id
12
+ --
13
+ -- workflowRunner NEVER READS THIS. A job step is an ordinary wait step whose
14
+ -- action happens to call the jobs API, so teaching the engine about the
15
+ -- distinction would buy nothing and would put a product concept ("a job") into
16
+ -- the one file that has managed to stay ignorant of every product concept so
17
+ -- far. If this column were dropped tomorrow every existing run would continue
18
+ -- to behave identically.
19
+ --
20
+ -- A STRING, NOT A BOOLEAN, because the next one is already visible: the same
21
+ -- canvas over saved API calls, and after that whatever else gets connected.
22
+ -- `is_job_workflow` would have to be replaced the day that lands, and every
23
+ -- row and query that referenced it with it.
24
+ --
25
+ -- Defaulted to 'workflow' so every workflow that exists today keeps opening
26
+ -- the way it always has.
27
+ ALTER TABLE workflows ADD COLUMN IF NOT EXISTS kind VARCHAR(32) NOT NULL DEFAULT 'workflow';
@@ -0,0 +1,48 @@
1
+ -- 0013_workflows_key.sql
2
+ -- `key` — HOW AN APP THAT IS NOT THIS ONE NAMES A WORKFLOW.
3
+ --
4
+ -- Added for kind = 'screens' (a "flow"): a workflow whose every step shows a
5
+ -- screen to a person and waits for them to submit it. A flow is designed and
6
+ -- run through the /flows facade (lib/flows.js) by a different app —
7
+ -- the one that owns the screens — and that app knows its flows by names it
8
+ -- chose ('onboard_employee'), not by ids minted here. Every /flows route is
9
+ -- addressed by this key.
10
+ --
11
+ -- ── why 'screens' needs no column of its own ────────────────────────────
12
+ --
13
+ -- `workflows.kind` (0012) is already an unconstrained VARCHAR(32) with no
14
+ -- CHECK and no enum type — it was written that way deliberately, because the
15
+ -- next kind after 'jobs' was already visible. 'screens' is that next kind, so
16
+ -- it lands with no DDL at all; the list of accepted values lives in the
17
+ -- model's jsonSchema, where a bad value is refused with the field named rather
18
+ -- than as a constraint violation.
19
+ --
20
+ -- The engine still never reads `kind` — a screen step is an ordinary wait step
21
+ -- whose action happens to do nothing. What the value decides is WHO MAY EDIT
22
+ -- THE ROW: the workflow document's own save route refuses a 'screens'
23
+ -- workflow, because those steps are generated from a design held in the other
24
+ -- app and a hand edit here would be silently overwritten by the next PUT
25
+ -- /flows/:key. Reading and running are untouched, so runs and history stay
26
+ -- visible in the builder.
27
+ --
28
+ -- ── the uniqueness rule ─────────────────────────────────────────────────
29
+ --
30
+ -- A key identifies a flow WITHIN A TENANT, not globally: two companies each
31
+ -- having an 'onboard_employee' flow is the normal case, and making the key
32
+ -- globally unique would let the first company to use a word take it from
33
+ -- everyone. So the index is over the tenant slots plus the key.
34
+ --
35
+ -- COALESCE on the mt columns because Postgres treats NULLs as distinct in a
36
+ -- unique index: an app that registered only one tenant level leaves mtId2
37
+ -- null, and without this every row would look unique to the index and the
38
+ -- duplicate it exists to prevent would be inserted happily.
39
+ --
40
+ -- Partial (WHERE "key" IS NOT NULL) because ordinary workflows and jobs
41
+ -- canvases have no key and never will — they are found by id — and hundreds of
42
+ -- null-keyed rows should not have to fit through this index.
43
+
44
+ ALTER TABLE "workflows" ADD COLUMN IF NOT EXISTS "key" varchar(64);
45
+
46
+ CREATE UNIQUE INDEX IF NOT EXISTS "workflows_key_unique"
47
+ ON "workflows" (COALESCE("mtId1", ''), COALESCE("mtId2", ''), "key")
48
+ WHERE "key" IS NOT NULL;
@@ -0,0 +1,129 @@
1
+ -- 0001_workflow_access.sql
2
+ -- xeplr-workflow's AUTH-DB extension data — layered on top of auth's base
3
+ -- migrations (runs after them: AUTH_EXT_MIGRATIONS_DIR). Adds this product's
4
+ -- roles + menus, maps Super Admin to everything, and applies the default
5
+ -- role→access matrix. All inserts are insert-if-absent (WHERE NOT EXISTS),
6
+ -- safe to re-run against an already-seeded DB.
7
+ --
8
+ -- Its OWN auth database (AUTH_DB_NAME=xeplr_workflow_auth), not xeplr-bi's.
9
+ -- Sharing one would mean a role granted in BI silently granting workflow
10
+ -- pages, which is not a thing anybody would have decided on purpose.
11
+
12
+ INSERT INTO "roles" (id, name, "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
13
+ SELECT encode(gen_random_bytes(12), 'hex'), name, true, '*', now(), now()
14
+ FROM (VALUES ('Super Admin'), ('CompanyAdmin'), ('Creator'), ('Viewer')) AS v(name)
15
+ WHERE NOT EXISTS (SELECT 1 FROM "roles" r WHERE r.name = v.name);
16
+
17
+ -- The drawer reads these: a menu row named here shows up in the nav for any
18
+ -- role mapped to it, and does not exist for a role that isn't.
19
+ -- The drawer reads these: a menu row named here shows up in the nav for any
20
+ -- role mapped to it, and does not exist for a role that isn't. Add the
21
+ -- workflow pages' rows in a LATER migration alongside the pages themselves —
22
+ -- seeding a menu for a page that does not exist yet puts a dead item in the
23
+ -- rail for everyone.
24
+ INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
25
+ SELECT encode(gen_random_bytes(12), 'hex'), name, "group", false, true, '*', now(), now()
26
+ FROM (VALUES
27
+ ('Home', 'workflows:view'),
28
+ ('Actions', 'workflows:view'),
29
+ ('Configuration', 'configuration:view'),
30
+ ('Access Control', 'configuration:access:view')
31
+ ) AS v(name, "group")
32
+ WHERE NOT EXISTS (SELECT 1 FROM "menus" m WHERE m.name = v.name);
33
+
34
+ -- This product's own APIs. Every route in routes/index.js that should be
35
+ -- gate-able gets a row here, grouped to match the menuGroup/apiGroup values
36
+ -- the role matrix below already reads. `/public/resume/:key` is isPublic —
37
+ -- it's never gated by role at all (a resume click isn't made by a signed-in
38
+ -- user), so it's grouped for bookkeeping only, not for the matrix below to
39
+ -- match against.
40
+ INSERT INTO "apis" (id, name, "apiGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
41
+ SELECT encode(gen_random_bytes(12), 'hex'), name, "group", "public", true, '*', now(), now()
42
+ FROM (VALUES
43
+ ('List workflows', 'workflows:view', false),
44
+ ('Get workflow', 'workflows:view', false),
45
+ ('List actions', 'workflows:view', false),
46
+ ('Save workflow', 'workflows:create', false),
47
+ ('Delete workflow', 'workflows:create', false),
48
+ ('Run workflow', 'workflows:run', false),
49
+ ('Resume workflow step', 'workflows:public', true)
50
+ ) AS v(name, "group", "public")
51
+ WHERE NOT EXISTS (SELECT 1 FROM "apis" a WHERE a."apiGroup" = v."group" AND a.name = v.name);
52
+
53
+ -- Super Admin → everything that now exists (base apis/pages/elements/menus +
54
+ -- this product's menus).
55
+ INSERT INTO "apisRolesMapping" (id, "roleId", "apiId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
56
+ SELECT encode(gen_random_bytes(12), 'hex'), r.id, a.id, true, '*', now(), now()
57
+ FROM "roles" r CROSS JOIN "apis" a
58
+ WHERE r.name = 'Super Admin'
59
+ AND NOT EXISTS (SELECT 1 FROM "apisRolesMapping" m WHERE m."roleId" = r.id AND m."apiId" = a.id);
60
+
61
+ INSERT INTO "uiPagesRolesMapping" (id, "roleId", "uiPageId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
62
+ SELECT encode(gen_random_bytes(12), 'hex'), r.id, p.id, true, '*', now(), now()
63
+ FROM "roles" r CROSS JOIN "uiPages" p
64
+ WHERE r.name = 'Super Admin'
65
+ AND NOT EXISTS (SELECT 1 FROM "uiPagesRolesMapping" m WHERE m."roleId" = r.id AND m."uiPageId" = p.id);
66
+
67
+ INSERT INTO "uiElementsRolesMapping" (id, "roleId", "uiElementId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
68
+ SELECT encode(gen_random_bytes(12), 'hex'), r.id, e.id, true, '*', now(), now()
69
+ FROM "roles" r CROSS JOIN "uiElements" e
70
+ WHERE r.name = 'Super Admin'
71
+ AND NOT EXISTS (SELECT 1 FROM "uiElementsRolesMapping" m WHERE m."roleId" = r.id AND m."uiElementId" = e.id);
72
+
73
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
74
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
75
+ FROM "roles" r CROSS JOIN "menus" m
76
+ WHERE r.name = 'Super Admin'
77
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
78
+
79
+ -- Scoped-role default access matrix (global WHAT; per-company/workspace scope
80
+ -- comes from userTenantsMapping, not from this).
81
+
82
+ -- CompanyAdmin: workflows:* and configuration:* (prefix).
83
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
84
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
85
+ FROM "roles" r CROSS JOIN "menus" m
86
+ WHERE r.name = 'CompanyAdmin'
87
+ AND (m."menuGroup" LIKE 'workflows:%' OR m."menuGroup" LIKE 'configuration:%')
88
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
89
+
90
+ INSERT INTO "apisRolesMapping" (id, "roleId", "apiId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
91
+ SELECT encode(gen_random_bytes(12), 'hex'), r.id, a.id, true, '*', now(), now()
92
+ FROM "roles" r CROSS JOIN "apis" a
93
+ WHERE r.name = 'CompanyAdmin'
94
+ AND (a."apiGroup" LIKE 'workflows:%' OR a."apiGroup" LIKE 'configuration:%')
95
+ AND NOT EXISTS (SELECT 1 FROM "apisRolesMapping" m WHERE m."roleId" = r.id AND m."apiId" = a.id);
96
+
97
+ -- Creator: can see, build and run. Note that RUNNING is deliberately a
98
+ -- creator's right and not a viewer's — a run has side effects (it sends mail,
99
+ -- moves files, writes to databases), so "can look at it" must not imply "can
100
+ -- fire it".
101
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
102
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
103
+ FROM "roles" r CROSS JOIN "menus" m
104
+ WHERE r.name = 'Creator'
105
+ AND m."menuGroup" IN ('workflows:view', 'workflows:create', 'workflows:run')
106
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
107
+
108
+ INSERT INTO "apisRolesMapping" (id, "roleId", "apiId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
109
+ SELECT encode(gen_random_bytes(12), 'hex'), r.id, a.id, true, '*', now(), now()
110
+ FROM "roles" r CROSS JOIN "apis" a
111
+ WHERE r.name = 'Creator'
112
+ AND a."apiGroup" IN ('workflows:view', 'workflows:create', 'workflows:run')
113
+ AND NOT EXISTS (SELECT 1 FROM "apisRolesMapping" m WHERE m."roleId" = r.id AND m."apiId" = a.id);
114
+
115
+ -- Viewer: workflows:view (exact). Sees the workflows and their history; fires
116
+ -- nothing.
117
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
118
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
119
+ FROM "roles" r CROSS JOIN "menus" m
120
+ WHERE r.name = 'Viewer'
121
+ AND m."menuGroup" = 'workflows:view'
122
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
123
+
124
+ INSERT INTO "apisRolesMapping" (id, "roleId", "apiId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
125
+ SELECT encode(gen_random_bytes(12), 'hex'), r.id, a.id, true, '*', now(), now()
126
+ FROM "roles" r CROSS JOIN "apis" a
127
+ WHERE r.name = 'Viewer'
128
+ AND a."apiGroup" = 'workflows:view'
129
+ AND NOT EXISTS (SELECT 1 FROM "apisRolesMapping" m WHERE m."roleId" = r.id AND m."apiId" = a.id);
@@ -0,0 +1,62 @@
1
+ -- 0003_nav_menus.sql
2
+ -- The rail's new items: Dashboards, Jobs, and the two scope pickers.
3
+ --
4
+ -- A SEPARATE MIGRATION rather than an edit to 0001, because 0001 has already
5
+ -- run — the migrator records what it has applied and will not re-run a file
6
+ -- whose contents changed. Editing it would seed nothing and leave the new items
7
+ -- role-filtered out of everyone's drawer, which looks exactly like a
8
+ -- permissions bug.
9
+ --
10
+ -- Seeded alongside the pages themselves, which is the rule 0001's comment
11
+ -- states: a menu row for a page that does not exist puts a dead item in the
12
+ -- rail for everyone. Dashboards and Jobs are placeholders today, but they are
13
+ -- real routes that render a real page saying so — which is a different thing
14
+ -- from a link that goes nowhere.
15
+ --
16
+ -- Every insert is insert-if-absent, safe to re-run.
17
+
18
+ INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
19
+ SELECT encode(gen_random_bytes(12), 'hex'), name, "group", false, true, '*', now(), now()
20
+ FROM (VALUES
21
+ ('Dashboards', 'workflows:view'),
22
+ ('Jobs', 'workflows:view'),
23
+ -- The scope pickers are workflows:view rather than configuration:*. Choosing
24
+ -- which workspace you are looking at is not administering anything, and a
25
+ -- Viewer who cannot switch workspace is a Viewer locked into whichever one
26
+ -- they happened to land in.
27
+ ('Select Workspace', 'workflows:view'),
28
+ ('Select Company', 'workflows:view')
29
+ ) AS v(name, "group")
30
+ WHERE NOT EXISTS (SELECT 1 FROM "menus" m WHERE m.name = v.name);
31
+
32
+ -- Super Admin → everything, including whatever was just added.
33
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
34
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
35
+ FROM "roles" r CROSS JOIN "menus" m
36
+ WHERE r.name = 'Super Admin'
37
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
38
+
39
+ -- And the scoped roles, on the same matrix 0001 established: CompanyAdmin gets
40
+ -- the workflows:* prefix, Creator and Viewer get exact groups. Re-run here so
41
+ -- the new rows are covered — 0001's inserts only saw the menus that existed
42
+ -- when it ran.
43
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
44
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
45
+ FROM "roles" r CROSS JOIN "menus" m
46
+ WHERE r.name = 'CompanyAdmin'
47
+ AND (m."menuGroup" LIKE 'workflows:%' OR m."menuGroup" LIKE 'configuration:%')
48
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
49
+
50
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
51
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
52
+ FROM "roles" r CROSS JOIN "menus" m
53
+ WHERE r.name = 'Creator'
54
+ AND m."menuGroup" IN ('workflows:view', 'workflows:create', 'workflows:run')
55
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
56
+
57
+ INSERT INTO "menuRolesMapping" (id, "menuId", "roleId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
58
+ SELECT encode(gen_random_bytes(12), 'hex'), m.id, r.id, true, '*', now(), now()
59
+ FROM "roles" r CROSS JOIN "menus" m
60
+ WHERE r.name = 'Viewer'
61
+ AND m."menuGroup" = 'workflows:view'
62
+ AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);