dzql 0.6.37 → 0.8.0
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 +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- package/src/sql/auth-jwt.sql +62 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain -> SQL compilation pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The CLI writes these strings to migration files; tests load the same strings
|
|
5
|
+
* into Postgres, so they exercise exactly the SQL the compiler produces.
|
|
6
|
+
*/
|
|
7
|
+
import type { DomainConfig } from "../shared/ir.js";
|
|
8
|
+
import { analyzeDomain } from "./compiler/analyzer.js";
|
|
9
|
+
import { generateIR } from "./compiler/ir.js";
|
|
10
|
+
import { validateDomainPaths } from "./compiler/path-validator.js";
|
|
11
|
+
import { generateCoreSQL, generateAuthSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
|
|
12
|
+
import { generateSubscribableSQL, generateComputeAffectedKeysFunction } from "./codegen/subscribable_sql.js";
|
|
13
|
+
|
|
14
|
+
export interface CompiledDomain {
|
|
15
|
+
ir: any;
|
|
16
|
+
coreSQL: string;
|
|
17
|
+
/** Tables, CRUD functions, auth functions and custom functions */
|
|
18
|
+
schemaSQL: string;
|
|
19
|
+
/** Subscribable functions plus compute_affected_keys */
|
|
20
|
+
subscribableSQL: string;
|
|
21
|
+
customFunctionCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DomainValidationError extends Error {
|
|
25
|
+
constructor(public errors: string[]) {
|
|
26
|
+
super(`Domain validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
|
27
|
+
this.name = 'DomainValidationError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function compileDomain(domain: DomainConfig): CompiledDomain {
|
|
32
|
+
const errors = analyzeDomain(domain);
|
|
33
|
+
if (errors.length > 0) {
|
|
34
|
+
throw new DomainValidationError(errors);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ir = generateIR(domain);
|
|
38
|
+
|
|
39
|
+
// Every permission, notification and canSubscribe path must compile against
|
|
40
|
+
// this domain. A path that can't is a build error, never FALSE or a fallback.
|
|
41
|
+
const pathErrors = validateDomainPaths(ir);
|
|
42
|
+
if (pathErrors.length > 0) {
|
|
43
|
+
throw new DomainValidationError(pathErrors);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const coreSQL = generateCoreSQL();
|
|
47
|
+
|
|
48
|
+
// Topologically sort entities by FK dependencies
|
|
49
|
+
// Entities must be created before entities that reference them
|
|
50
|
+
// Also detects circular FKs that need deferred constraint creation
|
|
51
|
+
const { sorted: sortedEntityNames, circularFKs } = topologicalSortEntities(ir.entities);
|
|
52
|
+
|
|
53
|
+
// Build set of circular FK columns for stripping during schema generation
|
|
54
|
+
const circularFKSet = new Set(circularFKs.map(fk => `${fk.table}.${fk.column}`));
|
|
55
|
+
|
|
56
|
+
const entitySQL: string[] = [];
|
|
57
|
+
for (const name of sortedEntityNames) {
|
|
58
|
+
const entityIR = ir.entities[name];
|
|
59
|
+
// Pass circular FK info so schema generation can strip those constraints
|
|
60
|
+
entitySQL.push(generateSchemaSQL(name, entityIR, circularFKSet));
|
|
61
|
+
// Skip CRUD generation for unmanaged entities (e.g., junction tables)
|
|
62
|
+
if (entityIR.managed !== false) {
|
|
63
|
+
entitySQL.push(generateEntitySQL(name, entityIR, ir.entities));
|
|
64
|
+
} else {
|
|
65
|
+
console.log(`[Compiler] Skipping CRUD for unmanaged entity: ${name}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Add deferred FK constraints at the end (for circular dependencies)
|
|
70
|
+
if (circularFKs.length > 0) {
|
|
71
|
+
entitySQL.push(generateDeferredFKConstraints(circularFKs));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Generate subscribable SQL functions
|
|
75
|
+
const subscribableSQL: string[] = [];
|
|
76
|
+
const subscribableNames = Object.keys(ir.subscribables);
|
|
77
|
+
for (const [name, subIR] of Object.entries(ir.subscribables)) {
|
|
78
|
+
console.log(`[Compiler] Generating SQL for subscribable: ${name}`);
|
|
79
|
+
subscribableSQL.push(generateSubscribableSQL(name, subIR as any, ir.entities));
|
|
80
|
+
}
|
|
81
|
+
// Generate central compute_affected_keys function that aggregates all subscribables
|
|
82
|
+
subscribableSQL.push(generateComputeAffectedKeysFunction(subscribableNames));
|
|
83
|
+
|
|
84
|
+
// Collect custom functions SQL
|
|
85
|
+
const customFunctionSQL: string[] = [];
|
|
86
|
+
for (const fn of ir.customFunctions) {
|
|
87
|
+
console.log(`[Compiler] Adding custom function: ${fn.name}`);
|
|
88
|
+
customFunctionSQL.push(fn.sql);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Combine entity SQL with auth functions and custom functions
|
|
92
|
+
// Auth functions must come after schema (they depend on users table)
|
|
93
|
+
// Columns a client may set at registration: those declared in
|
|
94
|
+
// auth.registerParams (beyond the built-in email/password/options), else
|
|
95
|
+
// just the users label field. Everything else is rejected at sign-up.
|
|
96
|
+
const usersEntity = ir.entities.users;
|
|
97
|
+
const declaredRegisterable = Object.keys(ir.auth?.registerParams || {})
|
|
98
|
+
.filter((k) => !["email", "password", "options"].includes(k));
|
|
99
|
+
const registerableColumns = declaredRegisterable.length > 0
|
|
100
|
+
? declaredRegisterable
|
|
101
|
+
: (usersEntity && usersEntity.labelField && usersEntity.labelField !== "id"
|
|
102
|
+
? [usersEntity.labelField]
|
|
103
|
+
: []);
|
|
104
|
+
const authSQL = generateAuthSQL(registerableColumns);
|
|
105
|
+
let schemaSQL = entitySQL.join('\n');
|
|
106
|
+
schemaSQL += '\n\n-- Auth Functions\n' + authSQL;
|
|
107
|
+
if (customFunctionSQL.length > 0) {
|
|
108
|
+
schemaSQL += '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
ir,
|
|
113
|
+
coreSQL,
|
|
114
|
+
schemaSQL,
|
|
115
|
+
subscribableSQL: subscribableSQL.join('\n\n'),
|
|
116
|
+
customFunctionCount: customFunctionSQL.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** FK info for dependency analysis */
|
|
121
|
+
interface FKInfo {
|
|
122
|
+
table: string;
|
|
123
|
+
column: string;
|
|
124
|
+
referencedTable: string;
|
|
125
|
+
fullConstraint: string; // e.g., "int NOT NULL REFERENCES users(id)"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Result of topological sort with cycle detection */
|
|
129
|
+
interface SortResult {
|
|
130
|
+
sorted: string[];
|
|
131
|
+
circularFKs: FKInfo[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Topologically sort entities based on FK dependencies.
|
|
136
|
+
* Entities that are referenced by others come first.
|
|
137
|
+
* Uses Kahn's algorithm for topological sorting.
|
|
138
|
+
* Returns both sorted order and any circular FK constraints that need deferred creation.
|
|
139
|
+
*/
|
|
140
|
+
function topologicalSortEntities(entities: Record<string, any>): SortResult {
|
|
141
|
+
const entityNames = Object.keys(entities);
|
|
142
|
+
|
|
143
|
+
// Build dependency graph: entity -> entities it depends on (references)
|
|
144
|
+
const dependencies: Record<string, Set<string>> = {};
|
|
145
|
+
const dependents: Record<string, Set<string>> = {};
|
|
146
|
+
|
|
147
|
+
// Track all FK info for cycle detection
|
|
148
|
+
const allFKs: FKInfo[] = [];
|
|
149
|
+
|
|
150
|
+
for (const name of entityNames) {
|
|
151
|
+
dependencies[name] = new Set();
|
|
152
|
+
dependents[name] = new Set();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Parse REFERENCES from column types
|
|
156
|
+
for (const name of entityNames) {
|
|
157
|
+
const entity = entities[name];
|
|
158
|
+
for (const col of entity.columns || []) {
|
|
159
|
+
const match = col.type?.match(/REFERENCES\s+(\w+)/i);
|
|
160
|
+
if (match) {
|
|
161
|
+
const referencedEntity = match[1];
|
|
162
|
+
// Only track dependencies to entities we're managing
|
|
163
|
+
if (entityNames.includes(referencedEntity)) {
|
|
164
|
+
dependencies[name].add(referencedEntity);
|
|
165
|
+
dependents[referencedEntity].add(name);
|
|
166
|
+
allFKs.push({
|
|
167
|
+
table: name,
|
|
168
|
+
column: col.name,
|
|
169
|
+
referencedTable: referencedEntity,
|
|
170
|
+
fullConstraint: col.type
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Kahn's algorithm
|
|
178
|
+
const result: string[] = [];
|
|
179
|
+
const noIncoming: string[] = [];
|
|
180
|
+
const remainingDeps: Record<string, Set<string>> = {};
|
|
181
|
+
|
|
182
|
+
// Copy dependencies for mutation
|
|
183
|
+
for (const name of entityNames) {
|
|
184
|
+
remainingDeps[name] = new Set(dependencies[name]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Find entities with no dependencies (no incoming edges)
|
|
188
|
+
for (const name of entityNames) {
|
|
189
|
+
if (remainingDeps[name].size === 0) {
|
|
190
|
+
noIncoming.push(name);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
while (noIncoming.length > 0) {
|
|
195
|
+
const node = noIncoming.shift()!;
|
|
196
|
+
result.push(node);
|
|
197
|
+
|
|
198
|
+
// Remove this node from the graph
|
|
199
|
+
for (const dependent of dependents[node]) {
|
|
200
|
+
remainingDeps[dependent].delete(node);
|
|
201
|
+
if (remainingDeps[dependent].size === 0) {
|
|
202
|
+
noIncoming.push(dependent);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Detect circular FKs
|
|
208
|
+
const circularFKs: FKInfo[] = [];
|
|
209
|
+
if (result.length !== entityNames.length) {
|
|
210
|
+
const cycleEntities = new Set(entityNames.filter(n => !result.includes(n)));
|
|
211
|
+
console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${[...cycleEntities].join(', ')}`);
|
|
212
|
+
|
|
213
|
+
// Find FKs that are part of the cycle - these need deferred creation
|
|
214
|
+
for (const fk of allFKs) {
|
|
215
|
+
if (cycleEntities.has(fk.table) && cycleEntities.has(fk.referencedTable)) {
|
|
216
|
+
circularFKs.push(fk);
|
|
217
|
+
console.log(`[Compiler] Deferring circular FK: ${fk.table}.${fk.column} -> ${fk.referencedTable}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Add remaining entities (cycle members) to result
|
|
222
|
+
result.push(...cycleEntities);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { sorted: result, circularFKs };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Generate ALTER TABLE statements for deferred circular FK constraints.
|
|
230
|
+
*/
|
|
231
|
+
function generateDeferredFKConstraints(circularFKs: FKInfo[]): string {
|
|
232
|
+
if (circularFKs.length === 0) return '';
|
|
233
|
+
|
|
234
|
+
const statements: string[] = [
|
|
235
|
+
'\n-- === DEFERRED FK CONSTRAINTS (for circular dependencies) ===\n'
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
for (const fk of circularFKs) {
|
|
239
|
+
// Extract the REFERENCES part from the full constraint
|
|
240
|
+
const refMatch = fk.fullConstraint.match(/REFERENCES\s+(\w+)(\([^)]+\))?(\s+ON\s+.+)?/i);
|
|
241
|
+
if (refMatch) {
|
|
242
|
+
const targetTable = refMatch[1];
|
|
243
|
+
const targetCol = refMatch[2] || '(id)';
|
|
244
|
+
const cascadeClause = refMatch[3] || '';
|
|
245
|
+
const constraintName = `fk_${fk.table}_${fk.column}`;
|
|
246
|
+
|
|
247
|
+
statements.push(`ALTER TABLE ${fk.table} ADD CONSTRAINT ${constraintName} FOREIGN KEY (${fk.column}) REFERENCES ${targetTable}${targetCol}${cascadeClause};`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return statements.join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Remove REFERENCES clause from a column type for deferred FK creation.
|
|
256
|
+
* Preserves NOT NULL and other modifiers.
|
|
257
|
+
*/
|
|
258
|
+
function stripReferences(columnType: string): string {
|
|
259
|
+
// Remove REFERENCES clause and any ON DELETE/UPDATE clauses
|
|
260
|
+
return columnType
|
|
261
|
+
.replace(/\s*REFERENCES\s+\w+(\([^)]+\))?(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION))*\s*/gi, ' ')
|
|
262
|
+
.trim();
|
|
263
|
+
}
|
|
@@ -1,118 +1,150 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Path Validator
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Checks every permission, notification and canSubscribe path in a domain
|
|
5
|
+
* against the domain itself: the syntax parses, every table is an entity,
|
|
6
|
+
* every column exists, and values compared with ids are integer keys.
|
|
7
|
+
* compileDomain refuses to build a domain with any error here, and the CLI
|
|
8
|
+
* `validate-paths` command reports them path by path.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
import type { DomainConfig
|
|
9
|
-
import {
|
|
11
|
+
import type { DomainConfig } from "../../shared/ir.js";
|
|
12
|
+
import { generateIR } from "./ir.js";
|
|
13
|
+
import { parsePath, isIntegerType } from "./paths.js";
|
|
10
14
|
|
|
11
15
|
export interface PathValidationResult {
|
|
12
16
|
entity: string;
|
|
13
|
-
pathType: 'permissions.view' | 'permissions.create' | 'permissions.update' | 'permissions.delete' | 'notifications';
|
|
17
|
+
pathType: 'permissions.view' | 'permissions.create' | 'permissions.update' | 'permissions.delete' | 'notifications' | 'canSubscribe';
|
|
14
18
|
pathIndex: number;
|
|
15
19
|
path: string;
|
|
16
20
|
valid: boolean;
|
|
17
21
|
error?: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
interface EntityLike {
|
|
25
|
+
columns: Array<{ name: string; type: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type PathKind = 'permission' | 'notification' | 'subscribe-list';
|
|
29
|
+
|
|
20
30
|
/**
|
|
21
|
-
*
|
|
31
|
+
* Check one path against the domain. Returns the problems found (empty if none).
|
|
32
|
+
*
|
|
33
|
+
* @param entity - the entity whose row the path starts from
|
|
34
|
+
* @param fieldMap - renames start fields (a subscribable's key param is the root's id)
|
|
22
35
|
*/
|
|
23
|
-
export function
|
|
24
|
-
|
|
36
|
+
export function checkPath(
|
|
37
|
+
rule: string,
|
|
38
|
+
entityName: string,
|
|
39
|
+
entities: Record<string, EntityLike>,
|
|
40
|
+
kind: PathKind = 'permission',
|
|
41
|
+
fieldMap: Record<string, string> = {}
|
|
42
|
+
): string[] {
|
|
43
|
+
const parsed = parsePath(rule);
|
|
44
|
+
if (!parsed.isValid) return [parsed.error ?? 'invalid path'];
|
|
45
|
+
|
|
46
|
+
if (parsed.literal) {
|
|
47
|
+
return kind === 'notification' ? [`a notification path must lead to users, not ${parsed.literal}`] : [];
|
|
48
|
+
}
|
|
49
|
+
if (kind === 'subscribe-list') {
|
|
50
|
+
return ['a list subscribable has no root row to follow a path from; use TRUE, FALSE or AUTHENTICATED'];
|
|
51
|
+
}
|
|
25
52
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
for (const permType of ['view', 'create', 'update', 'delete'] as const) {
|
|
31
|
-
const paths = permissions[permType] || [];
|
|
32
|
-
for (let i = 0; i < paths.length; i++) {
|
|
33
|
-
const path = paths[i];
|
|
34
|
-
const result = validateSinglePath(path, entityName, domain);
|
|
35
|
-
results.push({
|
|
36
|
-
entity: entityName,
|
|
37
|
-
pathType: `permissions.${permType}`,
|
|
38
|
-
pathIndex: i,
|
|
39
|
-
path,
|
|
40
|
-
valid: result.valid,
|
|
41
|
-
error: result.error
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
}
|
|
53
|
+
const problems: string[] = [];
|
|
54
|
+
const columnOf = (table: string, column: string) =>
|
|
55
|
+
entities[table]?.columns.find(c => c.name === column);
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const result = validateSinglePath(path, entityName, domain);
|
|
53
|
-
results.push({
|
|
54
|
-
entity: entityName,
|
|
55
|
-
pathType: 'notifications',
|
|
56
|
-
pathIndex: i,
|
|
57
|
-
path,
|
|
58
|
-
valid: result.valid,
|
|
59
|
-
error: result.error ? `${notifyName}: ${result.error}` : undefined
|
|
60
|
-
});
|
|
61
|
-
}
|
|
57
|
+
const requireColumn = (table: string, column: string, integer: boolean) => {
|
|
58
|
+
const col = columnOf(table, column);
|
|
59
|
+
if (!col) {
|
|
60
|
+
problems.push(`no column '${column}' on ${table}`);
|
|
61
|
+
} else if (integer && !isIntegerType(col.type)) {
|
|
62
|
+
problems.push(`'${column}' is ${col.type.split(/\s/)[0]}, not an integer key`);
|
|
62
63
|
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
path,
|
|
76
|
-
valid: result.valid,
|
|
77
|
-
error: result.error
|
|
78
|
-
});
|
|
64
|
+
};
|
|
65
|
+
// A field of the row being checked (its start field, or an @field in a filter)
|
|
66
|
+
const requireOwnField = (field: string) => requireColumn(entityName, fieldMap[field] ?? field, true);
|
|
67
|
+
|
|
68
|
+
if (!parsed.tableFirst) requireOwnField(parsed.startField!);
|
|
69
|
+
|
|
70
|
+
const steps = parsed.tableFirst ? parsed.hops : parsed.hops.slice(1);
|
|
71
|
+
for (const hop of steps) {
|
|
72
|
+
const table = hop.table!;
|
|
73
|
+
if (!entities[table]) {
|
|
74
|
+
problems.push(`unknown table '${table}'`);
|
|
75
|
+
continue;
|
|
79
76
|
}
|
|
77
|
+
// A hop without [filters] joins the previous value on its id.
|
|
78
|
+
if (!hop.filters?.length) requireColumn(table, 'id', true);
|
|
79
|
+
for (const f of hop.filters ?? []) {
|
|
80
|
+
requireColumn(table, f.field, f.value === '$');
|
|
81
|
+
if (f.value.startsWith('@')) requireOwnField(f.value.slice(1));
|
|
82
|
+
}
|
|
83
|
+
for (const c of hop.conditions ?? []) requireColumn(table, c.field, false);
|
|
84
|
+
// The value passed on joins the next hop's key; the last one is compared with the user.
|
|
85
|
+
requireColumn(table, hop.outputField!, true);
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
return
|
|
88
|
+
return problems;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
/**
|
|
86
|
-
* Validate a
|
|
92
|
+
* Validate every path in a compiled domain. Each error names where the path is
|
|
93
|
+
* written, what is wrong, and the path itself.
|
|
87
94
|
*/
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
domain: DomainConfig
|
|
92
|
-
): { valid: boolean; error?: string } {
|
|
93
|
-
// Parse the path
|
|
94
|
-
const parsed = parsePath(path);
|
|
95
|
-
|
|
96
|
-
if (!parsed.isValid) {
|
|
97
|
-
return { valid: false, error: parsed.error };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Optionally validate that referenced tables exist
|
|
101
|
-
const errors: string[] = [];
|
|
95
|
+
export function validateDomainPaths(ir: { entities: Record<string, any>; subscribables: Record<string, any> }): string[] {
|
|
96
|
+
return collectResults(ir).filter(r => !r.valid).map(r => r.error!);
|
|
97
|
+
}
|
|
102
98
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
99
|
+
function collectResults(ir: { entities: Record<string, any>; subscribables: Record<string, any> }): PathValidationResult[] {
|
|
100
|
+
const results: PathValidationResult[] = [];
|
|
101
|
+
const record = (entity: string, pathType: PathValidationResult['pathType'], where: string, pathIndex: number, path: string, problems: string[]) => {
|
|
102
|
+
results.push({
|
|
103
|
+
entity,
|
|
104
|
+
pathType,
|
|
105
|
+
pathIndex,
|
|
106
|
+
path,
|
|
107
|
+
valid: problems.length === 0,
|
|
108
|
+
error: problems.length ? `${where}: ${problems.join('; ')} — in '${path}'` : undefined,
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const [entityName, entity] of Object.entries<any>(ir.entities)) {
|
|
113
|
+
for (const action of ['view', 'create', 'update', 'delete'] as const) {
|
|
114
|
+
(entity.permissions?.[action] ?? []).forEach((path: string, i: number) => {
|
|
115
|
+
record(entityName, `permissions.${action}`, `${entityName}.permissions.${action}`, i, path,
|
|
116
|
+
checkPath(path, entityName, ir.entities));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (const [notifyName, paths] of Object.entries<any>(entity.notifications ?? {})) {
|
|
120
|
+
if (!Array.isArray(paths)) continue;
|
|
121
|
+
paths.forEach((path: string, i: number) => {
|
|
122
|
+
record(entityName, 'notifications', `${entityName}.notifications.${notifyName}`, i, path,
|
|
123
|
+
checkPath(path, entityName, ir.entities, 'notification'));
|
|
124
|
+
});
|
|
108
125
|
}
|
|
109
126
|
}
|
|
110
127
|
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
for (const [subName, sub] of Object.entries<any>(ir.subscribables)) {
|
|
129
|
+
const rootKey: string = sub.root?.key ?? '';
|
|
130
|
+
const isList = !rootKey;
|
|
131
|
+
const fieldMap = rootKey && !rootKey.startsWith('@') ? { [rootKey]: 'id' } : {};
|
|
132
|
+
(sub.canSubscribe ?? []).forEach((path: string, i: number) => {
|
|
133
|
+
const problems = !isList && !ir.entities[sub.root?.entity]
|
|
134
|
+
? [`unknown root entity '${sub.root?.entity}'`]
|
|
135
|
+
: checkPath(path, sub.root.entity, ir.entities, isList ? 'subscribe-list' : 'permission', fieldMap);
|
|
136
|
+
record(subName, 'canSubscribe', `${subName}.canSubscribe`, i, path, problems);
|
|
137
|
+
});
|
|
113
138
|
}
|
|
114
139
|
|
|
115
|
-
return
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Validate all paths in a domain configuration (for the CLI `validate-paths` command)
|
|
145
|
+
*/
|
|
146
|
+
export function validatePaths(domain: DomainConfig): PathValidationResult[] {
|
|
147
|
+
return collectResults(generateIR(domain));
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
/**
|