@facilis.dev/db 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/client.d.ts +78 -0
- package/dist/client.js +208 -0
- package/dist/files.d.ts +268 -0
- package/dist/files.js +88 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/jobs.d.ts +255 -0
- package/dist/jobs.js +64 -0
- package/dist/live-schema.d.ts +39 -0
- package/dist/live-schema.js +97 -0
- package/dist/migrator.d.ts +20 -0
- package/dist/migrator.js +66 -0
- package/dist/policy.d.ts +50 -0
- package/dist/policy.js +111 -0
- package/dist/query-console.d.ts +44 -0
- package/dist/query-console.js +173 -0
- package/dist/read-only-sql.d.ts +13 -0
- package/dist/read-only-sql.js +145 -0
- package/dist/rls.d.ts +37 -0
- package/dist/rls.js +90 -0
- package/dist/schema-digest.d.ts +97 -0
- package/dist/schema-digest.js +178 -0
- package/dist/schema-drift.d.ts +43 -0
- package/dist/schema-drift.js +176 -0
- package/dist/snapshot-readers.d.ts +3 -0
- package/dist/snapshot-readers.js +182 -0
- package/dist/snapshot.d.ts +92 -0
- package/dist/snapshot.js +21 -0
- package/dist/sudo-audit.d.ts +60 -0
- package/dist/sudo-audit.js +45 -0
- package/dist/sudo.d.ts +54 -0
- package/dist/sudo.js +137 -0
- package/dist/table-guard.d.ts +37 -0
- package/dist/table-guard.js +228 -0
- package/dist/tasks.d.ts +312 -0
- package/dist/tasks.js +52 -0
- package/dist/tenant-column.d.ts +21 -0
- package/dist/tenant-column.js +27 -0
- package/dist/views.d.ts +92 -0
- package/dist/views.js +186 -0
- package/dist/workflows.d.ts +289 -0
- package/dist/workflows.js +55 -0
- package/package.json +43 -4
- package/src/client.ts +291 -0
- package/src/files.ts +124 -0
- package/src/index.ts +174 -0
- package/src/jobs.ts +76 -0
- package/src/live-schema.ts +145 -0
- package/src/migrator.ts +92 -0
- package/src/policy.ts +174 -0
- package/src/query-console.ts +215 -0
- package/src/read-only-sql.ts +156 -0
- package/src/rls.ts +109 -0
- package/src/schema-digest.ts +240 -0
- package/src/schema-drift.ts +234 -0
- package/src/snapshot-readers.ts +212 -0
- package/src/snapshot.ts +122 -0
- package/src/sudo-audit.ts +91 -0
- package/src/sudo.ts +200 -0
- package/src/table-guard.ts +296 -0
- package/src/tasks.ts +79 -0
- package/src/tenant-column.ts +29 -0
- package/src/views.ts +291 -0
- package/src/workflows.ts +76 -0
package/dist/views.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Declarative SQL views (D-097/D-098, SPEC §11.2).
|
|
2
|
+
//
|
|
3
|
+
// A view is code: db/views/*.ts declares it with defineView(), and the framework emits
|
|
4
|
+
// CREATE OR REPLACE VIEW at boot (dev); production provisioning is the deploy step's job.
|
|
5
|
+
// Two modes (D-098):
|
|
6
|
+
// - no permission: security_invoker = true, so the base tables' RLS applies as facilis_app;
|
|
7
|
+
// - with a permission: security_invoker = false (definer), and that permission is the only
|
|
8
|
+
// gate — the view is a table-like resource whose access is its declared permission.
|
|
9
|
+
// The permission predicate is inlined in the DDL, so its name is validated first.
|
|
10
|
+
import { sql } from "drizzle-orm";
|
|
11
|
+
import { PgDialect, pgView, } from "drizzle-orm/pg-core";
|
|
12
|
+
import { APP_ROLE } from "./policy.js";
|
|
13
|
+
import { isReadOnlySql, stripTrailingSemicolon } from "./read-only-sql.js";
|
|
14
|
+
/** Brand carrier for a view object, so a declaration is recognisable from its export. */
|
|
15
|
+
export const VIEW_BRAND = Symbol.for("facilis.db.view");
|
|
16
|
+
/** SQL view names are unquoted identifiers; generated code always satisfies this. */
|
|
17
|
+
export const VIEW_NAME_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
|
|
18
|
+
/** A view's own permission, when declared. Same identifier shape the config uses. */
|
|
19
|
+
export const VIEW_PERMISSION_PATTERN = /^[a-z][a-z0-9:_-]{0,63}$/;
|
|
20
|
+
/** A declaration that cannot become a database view; the code reaches the dev endpoint. */
|
|
21
|
+
export class ViewDefinitionError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
constructor(code, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ViewDefinitionError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const dialect = new PgDialect();
|
|
30
|
+
/**
|
|
31
|
+
* Declare a SQL view. Never throws: an invalid declaration is reported when the framework
|
|
32
|
+
* tries to create it, so a typo in db/views cannot take the dev server down at import time.
|
|
33
|
+
*/
|
|
34
|
+
export function defineView(options) {
|
|
35
|
+
const view = pgView(options.name, options.columns).existing();
|
|
36
|
+
const permission = options.permission ?? null;
|
|
37
|
+
const metadata = {
|
|
38
|
+
name: options.name,
|
|
39
|
+
query: options.query,
|
|
40
|
+
tables: options.tables ?? [],
|
|
41
|
+
permission,
|
|
42
|
+
access: permission === null ? "invoker" : "definer",
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(view, VIEW_BRAND, { value: metadata, enumerable: false });
|
|
45
|
+
return view;
|
|
46
|
+
}
|
|
47
|
+
/** The declaration carried by a view object, when `value` is one. */
|
|
48
|
+
export function viewMetadata(value) {
|
|
49
|
+
if (typeof value !== "object" || value === null)
|
|
50
|
+
return null;
|
|
51
|
+
const metadata = Reflect.get(value, VIEW_BRAND);
|
|
52
|
+
if (typeof metadata !== "object" || metadata === null)
|
|
53
|
+
return null;
|
|
54
|
+
const record = metadata;
|
|
55
|
+
if (typeof record["name"] !== "string")
|
|
56
|
+
return null;
|
|
57
|
+
if (!isSql(record["query"]))
|
|
58
|
+
return null;
|
|
59
|
+
const tables = record["tables"];
|
|
60
|
+
const permission = record["permission"];
|
|
61
|
+
const declaredPermission = typeof permission === "string" ? permission : null;
|
|
62
|
+
return {
|
|
63
|
+
name: record["name"],
|
|
64
|
+
query: record["query"],
|
|
65
|
+
tables: Array.isArray(tables)
|
|
66
|
+
? tables.filter((entry) => typeof entry === "string")
|
|
67
|
+
: [],
|
|
68
|
+
permission: declaredPermission,
|
|
69
|
+
access: declaredPermission === null ? "invoker" : "definer",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function isFacilisView(value) {
|
|
73
|
+
return viewMetadata(value) !== null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Declarations in one or more module namespaces, deduplicated by name (first wins).
|
|
77
|
+
* A module namespace can export anything, so non-view exports are skipped.
|
|
78
|
+
*/
|
|
79
|
+
export function collectViewMetadata(modules) {
|
|
80
|
+
const namespaces = Array.isArray(modules) ? modules : [modules];
|
|
81
|
+
const byName = new Map();
|
|
82
|
+
for (const namespace of namespaces) {
|
|
83
|
+
for (const value of Object.values(namespace)) {
|
|
84
|
+
const metadata = viewMetadata(value);
|
|
85
|
+
if (metadata !== null && !byName.has(metadata.name))
|
|
86
|
+
byName.set(metadata.name, metadata);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...byName.values()];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* CREATE OR REPLACE VIEW for a declaration. Rejects a non-identifier name and a query
|
|
93
|
+
* that is not a single static read-only statement: the SQL is inlined into the DDL, so
|
|
94
|
+
* this check is the boundary, not a style rule.
|
|
95
|
+
*/
|
|
96
|
+
export function viewCreateStatement(metadata) {
|
|
97
|
+
return viewDefinitionStatement(metadata.name, metadata.query, metadata.permission ?? undefined);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* CREATE OR REPLACE VIEW for a raw SQL text (the query console's create-view flow).
|
|
101
|
+
*
|
|
102
|
+
* With a permission the view is a definer view whose only gate is that permission: the
|
|
103
|
+
* outer wrapper applies it to any query shape, and the permission name is validated before
|
|
104
|
+
* it is inlined (the DDL has no bind parameters, by construction).
|
|
105
|
+
*/
|
|
106
|
+
export function viewDefinitionStatement(name, query, permission) {
|
|
107
|
+
if (!VIEW_NAME_PATTERN.test(name)) {
|
|
108
|
+
throw new ViewDefinitionError("invalid_name", "A view name must match ^[a-z][a-z0-9_]{0,62}$ (lowercase letters, digits, underscore).");
|
|
109
|
+
}
|
|
110
|
+
if (permission !== undefined && !VIEW_PERMISSION_PATTERN.test(permission)) {
|
|
111
|
+
throw new ViewDefinitionError("invalid_permission", "A view permission must match ^[a-z][a-z0-9:_-]{0,63}$ (lowercase letters, digits, :, _, -).");
|
|
112
|
+
}
|
|
113
|
+
const text = typeof query === "string" ? stripTrailingSemicolon(query) : compileStaticQuery(query);
|
|
114
|
+
if (!isReadOnlySql(text)) {
|
|
115
|
+
throw new ViewDefinitionError("invalid_sql", "A view query must be one static read-only statement (SELECT, WITH, VALUES or TABLE).");
|
|
116
|
+
}
|
|
117
|
+
if (permission === undefined) {
|
|
118
|
+
return sql `create or replace view ${sql.identifier(name)} with (security_invoker = true) as ${sql.raw(text)}`;
|
|
119
|
+
}
|
|
120
|
+
return sql `create or replace view ${sql.identifier(name)} with (security_invoker = false) as select * from (${sql.raw(text)}) as "q" where nullif(current_setting('app.permissions', true), '')::jsonb ? ${sql.raw(`'${permission}'`)}`;
|
|
121
|
+
}
|
|
122
|
+
/** Grants SELECT on a view to the policy-scoped app role. Idempotent. */
|
|
123
|
+
export async function grantViewSelect(db, name) {
|
|
124
|
+
await db.db.execute(sql `grant select on ${sql.identifier(name)} to ${sql.identifier(APP_ROLE)}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Creates every declared view, then retries the failures once: a view may select from
|
|
128
|
+
* another view declared later in the same module. A failure is data, never a throw, so a
|
|
129
|
+
* broken view degrades to a manifest warning like every other boot step.
|
|
130
|
+
*/
|
|
131
|
+
export async function applyViews(db, modules, options = {}) {
|
|
132
|
+
const definitions = collectViewMetadata(modules);
|
|
133
|
+
const declared = options.permissions === undefined ? null : new Set(options.permissions);
|
|
134
|
+
const created = [];
|
|
135
|
+
const failures = new Map();
|
|
136
|
+
// An undeclared permission is a boot warning (view_failed), never a silent grant.
|
|
137
|
+
const allowed = definitions.filter((definition) => {
|
|
138
|
+
if (definition.permission === null || declared === null)
|
|
139
|
+
return true;
|
|
140
|
+
if (declared.has(definition.permission))
|
|
141
|
+
return true;
|
|
142
|
+
failures.set(definition.name, `permission "${definition.permission}" is not declared in facilis.config.ts auth.permissions`);
|
|
143
|
+
return false;
|
|
144
|
+
});
|
|
145
|
+
let pending = allowed;
|
|
146
|
+
for (let pass = 0; pass < 2 && pending.length > 0; pass += 1) {
|
|
147
|
+
const retry = [];
|
|
148
|
+
for (const definition of pending) {
|
|
149
|
+
try {
|
|
150
|
+
// oxlint-disable-next-line no-await-in-loop -- view creation is ordered: a view may select from one created earlier in this pass.
|
|
151
|
+
await db.db.execute(viewCreateStatement(definition));
|
|
152
|
+
// oxlint-disable-next-line no-await-in-loop -- the grant needs the view to exist; keep it right after its create.
|
|
153
|
+
await grantViewSelect(db, definition.name);
|
|
154
|
+
failures.delete(definition.name);
|
|
155
|
+
created.push(definition.name);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
retry.push(definition);
|
|
159
|
+
failures.set(definition.name, messageOf(error));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
pending = pass === 0 ? retry : [];
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
created,
|
|
166
|
+
failed: [...failures].map(([name, error]) => ({ name, error })),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Compile a literal SQL object and reject interpolated values (DDL has no parameters). */
|
|
170
|
+
function compileStaticQuery(query) {
|
|
171
|
+
const compiled = dialect.sqlToQuery(query);
|
|
172
|
+
if (compiled.params.length > 0) {
|
|
173
|
+
throw new ViewDefinitionError("invalid_sql", "A view query must be static: values cannot be interpolated into it.");
|
|
174
|
+
}
|
|
175
|
+
return stripTrailingSemicolon(compiled.sql);
|
|
176
|
+
}
|
|
177
|
+
function isSql(value) {
|
|
178
|
+
return (typeof value === "object" &&
|
|
179
|
+
value !== null &&
|
|
180
|
+
Array.isArray(value.queryChunks));
|
|
181
|
+
}
|
|
182
|
+
function messageOf(error) {
|
|
183
|
+
if (error instanceof Error)
|
|
184
|
+
return error.message;
|
|
185
|
+
return String(error);
|
|
186
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/** Every facilis_workflows.status value, in lifecycle order. */
|
|
2
|
+
export declare const FACILIS_WORKFLOW_STATUSES: readonly ["running", "waiting", "succeeded", "failed", "cancelled"];
|
|
3
|
+
export type FacilisWorkflowStatus = (typeof FACILIS_WORKFLOW_STATUSES)[number];
|
|
4
|
+
/**
|
|
5
|
+
* One workflow instance. Statuses stay text (a new state is a code change, not a migration),
|
|
6
|
+
* progress holds completed-step entries, plan the step identities captured at start so a
|
|
7
|
+
* resume after a definition edit fails with workflow_definition_changed instead of guessing.
|
|
8
|
+
*/
|
|
9
|
+
export declare const facilisWorkflows: Omit<import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
10
|
+
name: "facilis_workflows";
|
|
11
|
+
schema: undefined;
|
|
12
|
+
columns: {
|
|
13
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
14
|
+
name: "id";
|
|
15
|
+
tableName: "facilis_workflows";
|
|
16
|
+
dataType: "string";
|
|
17
|
+
columnType: "PgUUID";
|
|
18
|
+
data: string;
|
|
19
|
+
driverParam: string;
|
|
20
|
+
notNull: true;
|
|
21
|
+
hasDefault: true;
|
|
22
|
+
isPrimaryKey: true;
|
|
23
|
+
isAutoincrement: false;
|
|
24
|
+
hasRuntimeDefault: false;
|
|
25
|
+
enumValues: undefined;
|
|
26
|
+
baseColumn: never;
|
|
27
|
+
identity: undefined;
|
|
28
|
+
generated: undefined;
|
|
29
|
+
}, {}, {}>;
|
|
30
|
+
name: import("drizzle-orm/pg-core").PgColumn<{
|
|
31
|
+
name: "name";
|
|
32
|
+
tableName: "facilis_workflows";
|
|
33
|
+
dataType: "string";
|
|
34
|
+
columnType: "PgText";
|
|
35
|
+
data: string;
|
|
36
|
+
driverParam: string;
|
|
37
|
+
notNull: true;
|
|
38
|
+
hasDefault: false;
|
|
39
|
+
isPrimaryKey: false;
|
|
40
|
+
isAutoincrement: false;
|
|
41
|
+
hasRuntimeDefault: false;
|
|
42
|
+
enumValues: [string, ...string[]];
|
|
43
|
+
baseColumn: never;
|
|
44
|
+
identity: undefined;
|
|
45
|
+
generated: undefined;
|
|
46
|
+
}, {}, {}>;
|
|
47
|
+
status: import("drizzle-orm/pg-core").PgColumn<{
|
|
48
|
+
name: "status";
|
|
49
|
+
tableName: "facilis_workflows";
|
|
50
|
+
dataType: "string";
|
|
51
|
+
columnType: "PgText";
|
|
52
|
+
data: string;
|
|
53
|
+
driverParam: string;
|
|
54
|
+
notNull: true;
|
|
55
|
+
hasDefault: false;
|
|
56
|
+
isPrimaryKey: false;
|
|
57
|
+
isAutoincrement: false;
|
|
58
|
+
hasRuntimeDefault: false;
|
|
59
|
+
enumValues: [string, ...string[]];
|
|
60
|
+
baseColumn: never;
|
|
61
|
+
identity: undefined;
|
|
62
|
+
generated: undefined;
|
|
63
|
+
}, {}, {}>;
|
|
64
|
+
input: import("drizzle-orm/pg-core").PgColumn<{
|
|
65
|
+
name: "input";
|
|
66
|
+
tableName: "facilis_workflows";
|
|
67
|
+
dataType: "json";
|
|
68
|
+
columnType: "PgJsonb";
|
|
69
|
+
data: unknown;
|
|
70
|
+
driverParam: unknown;
|
|
71
|
+
notNull: true;
|
|
72
|
+
hasDefault: false;
|
|
73
|
+
isPrimaryKey: false;
|
|
74
|
+
isAutoincrement: false;
|
|
75
|
+
hasRuntimeDefault: false;
|
|
76
|
+
enumValues: undefined;
|
|
77
|
+
baseColumn: never;
|
|
78
|
+
identity: undefined;
|
|
79
|
+
generated: undefined;
|
|
80
|
+
}, {}, {}>;
|
|
81
|
+
progress: import("drizzle-orm/pg-core").PgColumn<{
|
|
82
|
+
name: "progress";
|
|
83
|
+
tableName: "facilis_workflows";
|
|
84
|
+
dataType: "json";
|
|
85
|
+
columnType: "PgJsonb";
|
|
86
|
+
data: unknown;
|
|
87
|
+
driverParam: unknown;
|
|
88
|
+
notNull: true;
|
|
89
|
+
hasDefault: true;
|
|
90
|
+
isPrimaryKey: false;
|
|
91
|
+
isAutoincrement: false;
|
|
92
|
+
hasRuntimeDefault: false;
|
|
93
|
+
enumValues: undefined;
|
|
94
|
+
baseColumn: never;
|
|
95
|
+
identity: undefined;
|
|
96
|
+
generated: undefined;
|
|
97
|
+
}, {}, {}>;
|
|
98
|
+
plan: import("drizzle-orm/pg-core").PgColumn<{
|
|
99
|
+
name: "plan";
|
|
100
|
+
tableName: "facilis_workflows";
|
|
101
|
+
dataType: "json";
|
|
102
|
+
columnType: "PgJsonb";
|
|
103
|
+
data: unknown;
|
|
104
|
+
driverParam: unknown;
|
|
105
|
+
notNull: true;
|
|
106
|
+
hasDefault: true;
|
|
107
|
+
isPrimaryKey: false;
|
|
108
|
+
isAutoincrement: false;
|
|
109
|
+
hasRuntimeDefault: false;
|
|
110
|
+
enumValues: undefined;
|
|
111
|
+
baseColumn: never;
|
|
112
|
+
identity: undefined;
|
|
113
|
+
generated: undefined;
|
|
114
|
+
}, {}, {}>;
|
|
115
|
+
currentStep: import("drizzle-orm/pg-core").PgColumn<{
|
|
116
|
+
name: "current_step";
|
|
117
|
+
tableName: "facilis_workflows";
|
|
118
|
+
dataType: "number";
|
|
119
|
+
columnType: "PgInteger";
|
|
120
|
+
data: number;
|
|
121
|
+
driverParam: string | number;
|
|
122
|
+
notNull: true;
|
|
123
|
+
hasDefault: true;
|
|
124
|
+
isPrimaryKey: false;
|
|
125
|
+
isAutoincrement: false;
|
|
126
|
+
hasRuntimeDefault: false;
|
|
127
|
+
enumValues: undefined;
|
|
128
|
+
baseColumn: never;
|
|
129
|
+
identity: undefined;
|
|
130
|
+
generated: undefined;
|
|
131
|
+
}, {}, {}>;
|
|
132
|
+
currentJobId: import("drizzle-orm/pg-core").PgColumn<{
|
|
133
|
+
name: "current_job_id";
|
|
134
|
+
tableName: "facilis_workflows";
|
|
135
|
+
dataType: "string";
|
|
136
|
+
columnType: "PgUUID";
|
|
137
|
+
data: string;
|
|
138
|
+
driverParam: string;
|
|
139
|
+
notNull: false;
|
|
140
|
+
hasDefault: false;
|
|
141
|
+
isPrimaryKey: false;
|
|
142
|
+
isAutoincrement: false;
|
|
143
|
+
hasRuntimeDefault: false;
|
|
144
|
+
enumValues: undefined;
|
|
145
|
+
baseColumn: never;
|
|
146
|
+
identity: undefined;
|
|
147
|
+
generated: undefined;
|
|
148
|
+
}, {}, {}>;
|
|
149
|
+
waitingTaskId: import("drizzle-orm/pg-core").PgColumn<{
|
|
150
|
+
name: "waiting_task_id";
|
|
151
|
+
tableName: "facilis_workflows";
|
|
152
|
+
dataType: "string";
|
|
153
|
+
columnType: "PgUUID";
|
|
154
|
+
data: string;
|
|
155
|
+
driverParam: string;
|
|
156
|
+
notNull: false;
|
|
157
|
+
hasDefault: false;
|
|
158
|
+
isPrimaryKey: false;
|
|
159
|
+
isAutoincrement: false;
|
|
160
|
+
hasRuntimeDefault: false;
|
|
161
|
+
enumValues: undefined;
|
|
162
|
+
baseColumn: never;
|
|
163
|
+
identity: undefined;
|
|
164
|
+
generated: undefined;
|
|
165
|
+
}, {}, {}>;
|
|
166
|
+
attempts: import("drizzle-orm/pg-core").PgColumn<{
|
|
167
|
+
name: "attempts";
|
|
168
|
+
tableName: "facilis_workflows";
|
|
169
|
+
dataType: "number";
|
|
170
|
+
columnType: "PgInteger";
|
|
171
|
+
data: number;
|
|
172
|
+
driverParam: string | number;
|
|
173
|
+
notNull: true;
|
|
174
|
+
hasDefault: true;
|
|
175
|
+
isPrimaryKey: false;
|
|
176
|
+
isAutoincrement: false;
|
|
177
|
+
hasRuntimeDefault: false;
|
|
178
|
+
enumValues: undefined;
|
|
179
|
+
baseColumn: never;
|
|
180
|
+
identity: undefined;
|
|
181
|
+
generated: undefined;
|
|
182
|
+
}, {}, {}>;
|
|
183
|
+
output: import("drizzle-orm/pg-core").PgColumn<{
|
|
184
|
+
name: "output";
|
|
185
|
+
tableName: "facilis_workflows";
|
|
186
|
+
dataType: "json";
|
|
187
|
+
columnType: "PgJsonb";
|
|
188
|
+
data: unknown;
|
|
189
|
+
driverParam: unknown;
|
|
190
|
+
notNull: false;
|
|
191
|
+
hasDefault: false;
|
|
192
|
+
isPrimaryKey: false;
|
|
193
|
+
isAutoincrement: false;
|
|
194
|
+
hasRuntimeDefault: false;
|
|
195
|
+
enumValues: undefined;
|
|
196
|
+
baseColumn: never;
|
|
197
|
+
identity: undefined;
|
|
198
|
+
generated: undefined;
|
|
199
|
+
}, {}, {}>;
|
|
200
|
+
error: import("drizzle-orm/pg-core").PgColumn<{
|
|
201
|
+
name: "error";
|
|
202
|
+
tableName: "facilis_workflows";
|
|
203
|
+
dataType: "string";
|
|
204
|
+
columnType: "PgText";
|
|
205
|
+
data: string;
|
|
206
|
+
driverParam: string;
|
|
207
|
+
notNull: false;
|
|
208
|
+
hasDefault: false;
|
|
209
|
+
isPrimaryKey: false;
|
|
210
|
+
isAutoincrement: false;
|
|
211
|
+
hasRuntimeDefault: false;
|
|
212
|
+
enumValues: [string, ...string[]];
|
|
213
|
+
baseColumn: never;
|
|
214
|
+
identity: undefined;
|
|
215
|
+
generated: undefined;
|
|
216
|
+
}, {}, {}>;
|
|
217
|
+
startedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
218
|
+
name: "started_at";
|
|
219
|
+
tableName: "facilis_workflows";
|
|
220
|
+
dataType: "date";
|
|
221
|
+
columnType: "PgTimestamp";
|
|
222
|
+
data: Date;
|
|
223
|
+
driverParam: string;
|
|
224
|
+
notNull: true;
|
|
225
|
+
hasDefault: true;
|
|
226
|
+
isPrimaryKey: false;
|
|
227
|
+
isAutoincrement: false;
|
|
228
|
+
hasRuntimeDefault: false;
|
|
229
|
+
enumValues: undefined;
|
|
230
|
+
baseColumn: never;
|
|
231
|
+
identity: undefined;
|
|
232
|
+
generated: undefined;
|
|
233
|
+
}, {}, {}>;
|
|
234
|
+
finishedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
235
|
+
name: "finished_at";
|
|
236
|
+
tableName: "facilis_workflows";
|
|
237
|
+
dataType: "date";
|
|
238
|
+
columnType: "PgTimestamp";
|
|
239
|
+
data: Date;
|
|
240
|
+
driverParam: string;
|
|
241
|
+
notNull: false;
|
|
242
|
+
hasDefault: false;
|
|
243
|
+
isPrimaryKey: false;
|
|
244
|
+
isAutoincrement: false;
|
|
245
|
+
hasRuntimeDefault: false;
|
|
246
|
+
enumValues: undefined;
|
|
247
|
+
baseColumn: never;
|
|
248
|
+
identity: undefined;
|
|
249
|
+
generated: undefined;
|
|
250
|
+
}, {}, {}>;
|
|
251
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
252
|
+
name: "created_at";
|
|
253
|
+
tableName: "facilis_workflows";
|
|
254
|
+
dataType: "date";
|
|
255
|
+
columnType: "PgTimestamp";
|
|
256
|
+
data: Date;
|
|
257
|
+
driverParam: string;
|
|
258
|
+
notNull: true;
|
|
259
|
+
hasDefault: true;
|
|
260
|
+
isPrimaryKey: false;
|
|
261
|
+
isAutoincrement: false;
|
|
262
|
+
hasRuntimeDefault: false;
|
|
263
|
+
enumValues: undefined;
|
|
264
|
+
baseColumn: never;
|
|
265
|
+
identity: undefined;
|
|
266
|
+
generated: undefined;
|
|
267
|
+
}, {}, {}>;
|
|
268
|
+
tenantId: import("drizzle-orm/pg-core").PgColumn<{
|
|
269
|
+
name: string;
|
|
270
|
+
tableName: "facilis_workflows";
|
|
271
|
+
dataType: "string";
|
|
272
|
+
columnType: "PgUUID";
|
|
273
|
+
data: string;
|
|
274
|
+
driverParam: string;
|
|
275
|
+
notNull: true;
|
|
276
|
+
hasDefault: true;
|
|
277
|
+
isPrimaryKey: false;
|
|
278
|
+
isAutoincrement: false;
|
|
279
|
+
hasRuntimeDefault: false;
|
|
280
|
+
enumValues: undefined;
|
|
281
|
+
baseColumn: never;
|
|
282
|
+
identity: undefined;
|
|
283
|
+
generated: undefined;
|
|
284
|
+
}, {}, {}>;
|
|
285
|
+
};
|
|
286
|
+
dialect: 'pg';
|
|
287
|
+
}>, "enableRLS">;
|
|
288
|
+
export type FacilisWorkflowRow = typeof facilisWorkflows.$inferSelect;
|
|
289
|
+
export type NewFacilisWorkflowRow = typeof facilisWorkflows.$inferInsert;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The workflow table (D-135, docs/WORKFLOWS.md §5).
|
|
2
|
+
//
|
|
3
|
+
// A workflow is framework state, not application data: tenant-scoped so the structural
|
|
4
|
+
// invariant holds (pgPolicy + ENABLE ROW LEVEL SECURITY), re-exported by the project's
|
|
5
|
+
// db/schema.ts, and DDL-owned by a Drizzle migration like the job queue (D-107).
|
|
6
|
+
//
|
|
7
|
+
// The chain itself is data: the runner advances rows with short compare-and-set writes and a
|
|
8
|
+
// manual step waits on a facilis_tasks row, so no job body ever holds a transaction open.
|
|
9
|
+
import { sql } from "drizzle-orm";
|
|
10
|
+
import { index, integer, jsonb, pgPolicy, pgTable, text, timestamp, uuid, } from "drizzle-orm/pg-core";
|
|
11
|
+
import { tenantIsolationPolicy } from "./rls.js";
|
|
12
|
+
import { tenantColumn } from "./tenant-column.js";
|
|
13
|
+
/** Every facilis_workflows.status value, in lifecycle order. */
|
|
14
|
+
export const FACILIS_WORKFLOW_STATUSES = [
|
|
15
|
+
"running",
|
|
16
|
+
"waiting",
|
|
17
|
+
"succeeded",
|
|
18
|
+
"failed",
|
|
19
|
+
"cancelled",
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* One workflow instance. Statuses stay text (a new state is a code change, not a migration),
|
|
23
|
+
* progress holds completed-step entries, plan the step identities captured at start so a
|
|
24
|
+
* resume after a definition edit fails with workflow_definition_changed instead of guessing.
|
|
25
|
+
*/
|
|
26
|
+
export const facilisWorkflows = pgTable("facilis_workflows", {
|
|
27
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
28
|
+
name: text("name").notNull(),
|
|
29
|
+
status: text("status").notNull(),
|
|
30
|
+
input: jsonb("input").notNull(),
|
|
31
|
+
progress: jsonb("progress").notNull().default([]),
|
|
32
|
+
plan: jsonb("plan").notNull().default([]),
|
|
33
|
+
currentStep: integer("current_step").notNull().default(0),
|
|
34
|
+
currentJobId: uuid("current_job_id"),
|
|
35
|
+
waitingTaskId: uuid("waiting_task_id"),
|
|
36
|
+
attempts: integer("attempts").notNull().default(0),
|
|
37
|
+
output: jsonb("output"),
|
|
38
|
+
error: text("error"),
|
|
39
|
+
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
|
40
|
+
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
|
41
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
42
|
+
tenantId: tenantColumn(),
|
|
43
|
+
}, (table) => [
|
|
44
|
+
tenantIsolationPolicy(),
|
|
45
|
+
// A RESTRICTIVE policy only narrows what a permissive policy already allows; with no
|
|
46
|
+
// permissive policy Postgres denies every row (D-008). The runner's writes run as
|
|
47
|
+
// facilis_app, so the scoped tenant needs this gate - exactly like facilis_jobs.
|
|
48
|
+
pgPolicy("facilis_workflows_tenant_access", {
|
|
49
|
+
for: "all",
|
|
50
|
+
using: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
51
|
+
withCheck: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
52
|
+
}),
|
|
53
|
+
index("facilis_workflows_status_created_idx").on(table.status, table.createdAt),
|
|
54
|
+
index("facilis_workflows_name_idx").on(table.name),
|
|
55
|
+
]).enableRLS();
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@facilis.dev/db",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Facilis database layer: Drizzle + PGlite client, RLS policy helpers and release diffing.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.15.0"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@electric-sql/pglite": "0.5.11",
|
|
22
|
+
"drizzle-orm": "0.45.4"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/facilis-dev/facilis.git",
|
|
30
|
+
"directory": "packages/db"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/facilis-dev/facilis/tree/main/packages/db#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/facilis-dev/facilis/issues"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"facilis",
|
|
38
|
+
"framework",
|
|
39
|
+
"vite",
|
|
40
|
+
"nitro",
|
|
41
|
+
"low-code"
|
|
42
|
+
],
|
|
43
|
+
"main": "./dist/index.js",
|
|
44
|
+
"types": "./dist/index.d.ts"
|
|
45
|
+
}
|