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.
Files changed (45) hide show
  1. package/README.md +16 -0
  2. package/docs/README.md +1 -1
  3. package/docs/for_ai.md +39 -9
  4. package/package.json +12 -5
  5. package/src/cli/codegen/notification.ts +3 -3
  6. package/src/cli/codegen/sql/core.ts +21 -5
  7. package/src/cli/codegen/sql/delete.ts +5 -7
  8. package/src/cli/codegen/sql/get.ts +9 -11
  9. package/src/cli/codegen/sql/index.ts +7 -4
  10. package/src/cli/codegen/sql/lookup.ts +4 -5
  11. package/src/cli/codegen/sql/save.ts +58 -39
  12. package/src/cli/codegen/sql/search.ts +44 -10
  13. package/src/cli/codegen/sql/utils.ts +16 -1
  14. package/src/cli/codegen/subscribable_sql.ts +116 -124
  15. package/src/cli/compile.ts +263 -0
  16. package/src/cli/compiler/path-validator.ts +115 -83
  17. package/src/cli/compiler/paths.ts +299 -379
  18. package/src/cli/compiler/permissions.ts +26 -26
  19. package/src/cli/index.ts +17 -219
  20. package/src/client/ws.ts +20 -10
  21. package/src/postgres/auth-jwt-sql.ts +32 -0
  22. package/src/postgres/auth-jwt.ts +209 -0
  23. package/src/postgres/auth.ts +50 -0
  24. package/src/postgres/bootstrap.ts +55 -0
  25. package/src/postgres/carry.ts +40 -0
  26. package/src/postgres/codegen.ts +172 -0
  27. package/src/postgres/index.ts +58 -0
  28. package/src/postgres/listener.ts +706 -0
  29. package/src/postgres/migrate.ts +143 -0
  30. package/src/postgres/pglite.ts +142 -0
  31. package/src/postgres/registry.ts +253 -0
  32. package/src/postgres/schema.ts +171 -0
  33. package/src/postgres/sql.ts +42 -0
  34. package/src/runtime/auth.ts +33 -2
  35. package/src/runtime/index.ts +47 -6
  36. package/src/runtime/manifest_loader.ts +10 -0
  37. package/src/runtime/projection.ts +19 -0
  38. package/src/sql/001a-delta-meta.sql +204 -0
  39. package/src/sql/001b-delta-scope.sql +619 -0
  40. package/src/sql/001c-delta-read.sql +506 -0
  41. package/src/sql/001d-delta-write.sql +1088 -0
  42. package/src/sql/001e-delta-ops.sql +142 -0
  43. package/src/sql/001f-delta-as.sql +41 -0
  44. package/src/sql/001g-delta-ledger.sql +564 -0
  45. 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
- * Validates all permission and notification paths in a domain configuration.
5
- * Used by the CLI `validate-paths` command.
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, EntityConfig } from "../../shared/ir.js";
9
- import { parsePath } from "./paths.js";
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
- * Validate all paths in a domain configuration
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 validatePaths(domain: DomainConfig): PathValidationResult[] {
24
- const results: PathValidationResult[] = [];
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
- for (const [entityName, entity] of Object.entries(domain.entities)) {
27
- // Validate permission paths
28
- const permissions = entity.permissions || {};
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
- // Validate notification paths
47
- const notifications = (entity as any).notifications || {};
48
- for (const [notifyName, paths] of Object.entries(notifications)) {
49
- if (!Array.isArray(paths)) continue;
50
- for (let i = 0; i < paths.length; i++) {
51
- const path = paths[i] as string;
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
- // Validate subscribable canSubscribe paths
66
- for (const [subName, sub] of Object.entries(domain.subscribables || {})) {
67
- const paths = sub.canSubscribe || [];
68
- for (let i = 0; i < paths.length; i++) {
69
- const path = paths[i];
70
- const result = validateSinglePath(path, subName, domain);
71
- results.push({
72
- entity: subName,
73
- pathType: 'permissions.view',
74
- pathIndex: i,
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 results;
88
+ return problems;
83
89
  }
84
90
 
85
91
  /**
86
- * Validate a single path expression
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 validateSinglePath(
89
- path: string,
90
- entityName: string,
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
- for (const hop of parsed.hops) {
104
- if (hop.table && hop.type !== 'field') {
105
- if (!domain.entities[hop.table]) {
106
- errors.push(`Unknown table '${hop.table}'`);
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
- if (errors.length > 0) {
112
- return { valid: false, error: errors.join('; ') };
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 { valid: true };
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
  /**