dzql 0.5.33 → 0.6.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 (142) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +309 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +653 -0
  20. package/docs/project-setup.md +456 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +166 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/runtime/auth.ts +39 -0
  42. package/src/runtime/db.ts +33 -0
  43. package/src/runtime/errors.ts +51 -0
  44. package/src/runtime/index.ts +98 -0
  45. package/src/runtime/js_functions.ts +63 -0
  46. package/src/runtime/manifest_loader.ts +29 -0
  47. package/src/runtime/namespace.ts +483 -0
  48. package/src/runtime/server.ts +87 -0
  49. package/src/runtime/ws.ts +197 -0
  50. package/src/shared/ir.ts +197 -0
  51. package/tests/client.test.ts +38 -0
  52. package/tests/codegen.test.ts +71 -0
  53. package/tests/compiler.test.ts +45 -0
  54. package/tests/graph_rules.test.ts +173 -0
  55. package/tests/integration/db.test.ts +174 -0
  56. package/tests/integration/e2e.test.ts +65 -0
  57. package/tests/integration/features.test.ts +922 -0
  58. package/tests/integration/full_stack.test.ts +262 -0
  59. package/tests/integration/setup.ts +45 -0
  60. package/tests/ir.test.ts +32 -0
  61. package/tests/namespace.test.ts +395 -0
  62. package/tests/permissions.test.ts +55 -0
  63. package/tests/pinia.test.ts +48 -0
  64. package/tests/realtime.test.ts +22 -0
  65. package/tests/runtime.test.ts +80 -0
  66. package/tests/subscribable_gen.test.ts +72 -0
  67. package/tests/subscribable_reactivity.test.ts +258 -0
  68. package/tests/venues_gen.test.ts +25 -0
  69. package/tsconfig.json +20 -0
  70. package/tsconfig.tsbuildinfo +1 -0
  71. package/README.md +0 -90
  72. package/bin/cli.js +0 -727
  73. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  74. package/docs/compiler/CODING_STANDARDS.md +0 -415
  75. package/docs/compiler/COMPARISON.md +0 -673
  76. package/docs/compiler/QUICKSTART.md +0 -326
  77. package/docs/compiler/README.md +0 -134
  78. package/docs/examples/README.md +0 -38
  79. package/docs/examples/blog.sql +0 -160
  80. package/docs/examples/venue-detail-simple.sql +0 -8
  81. package/docs/examples/venue-detail-subscribable.sql +0 -45
  82. package/docs/for-ai/claude-guide.md +0 -1210
  83. package/docs/getting-started/quickstart.md +0 -125
  84. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  85. package/docs/getting-started/tutorial.md +0 -1104
  86. package/docs/guides/atomic-updates.md +0 -299
  87. package/docs/guides/client-stores.md +0 -730
  88. package/docs/guides/composite-primary-keys.md +0 -158
  89. package/docs/guides/custom-functions.md +0 -362
  90. package/docs/guides/drop-semantics.md +0 -554
  91. package/docs/guides/field-defaults.md +0 -240
  92. package/docs/guides/interpreter-vs-compiler.md +0 -237
  93. package/docs/guides/many-to-many.md +0 -929
  94. package/docs/guides/subscriptions.md +0 -537
  95. package/docs/reference/api.md +0 -1373
  96. package/docs/reference/client.md +0 -224
  97. package/src/client/stores/index.js +0 -8
  98. package/src/client/stores/useAppStore.js +0 -285
  99. package/src/client/stores/useWsStore.js +0 -289
  100. package/src/client/ws.js +0 -762
  101. package/src/compiler/cli/compile-example.js +0 -33
  102. package/src/compiler/cli/compile-subscribable.js +0 -43
  103. package/src/compiler/cli/debug-compile.js +0 -44
  104. package/src/compiler/cli/debug-parse.js +0 -26
  105. package/src/compiler/cli/debug-path-parser.js +0 -18
  106. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  107. package/src/compiler/cli/index.js +0 -174
  108. package/src/compiler/codegen/auth-codegen.js +0 -153
  109. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  110. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  111. package/src/compiler/codegen/notification-codegen.js +0 -232
  112. package/src/compiler/codegen/operation-codegen.js +0 -1382
  113. package/src/compiler/codegen/permission-codegen.js +0 -318
  114. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  115. package/src/compiler/compiler.js +0 -371
  116. package/src/compiler/index.js +0 -11
  117. package/src/compiler/parser/entity-parser.js +0 -440
  118. package/src/compiler/parser/path-parser.js +0 -290
  119. package/src/compiler/parser/subscribable-parser.js +0 -244
  120. package/src/database/dzql-core.sql +0 -161
  121. package/src/database/migrations/001_schema.sql +0 -60
  122. package/src/database/migrations/002_functions.sql +0 -890
  123. package/src/database/migrations/003_operations.sql +0 -1135
  124. package/src/database/migrations/004_search.sql +0 -581
  125. package/src/database/migrations/005_entities.sql +0 -730
  126. package/src/database/migrations/006_auth.sql +0 -94
  127. package/src/database/migrations/007_events.sql +0 -133
  128. package/src/database/migrations/008_hello.sql +0 -18
  129. package/src/database/migrations/008a_meta.sql +0 -172
  130. package/src/database/migrations/009_subscriptions.sql +0 -240
  131. package/src/database/migrations/010_atomic_updates.sql +0 -157
  132. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  133. package/src/index.js +0 -40
  134. package/src/server/api.js +0 -9
  135. package/src/server/db.js +0 -442
  136. package/src/server/index.js +0 -317
  137. package/src/server/logger.js +0 -259
  138. package/src/server/mcp.js +0 -594
  139. package/src/server/meta-route.js +0 -251
  140. package/src/server/namespace.js +0 -292
  141. package/src/server/subscriptions.js +0 -351
  142. package/src/server/ws.js +0 -573
@@ -0,0 +1,698 @@
1
+ import { compilePermission } from "../compiler/permissions.js";
2
+ import { compileGraphRules } from "../compiler/graph_rules.js";
3
+ import type { EntityIR, ManyToManyIR } from "../../shared/ir.js";
4
+
5
+ /** Column info from EntityIR */
6
+ interface ColumnInfo {
7
+ name: string;
8
+ type: string;
9
+ isArray: boolean;
10
+ }
11
+
12
+ /**
13
+ * Generate a jsonb_build_object expression that excludes hidden fields.
14
+ * If no hidden fields, returns to_jsonb(alias.*) for efficiency.
15
+ * @param alias - Table alias (e.g., 'venues', 't', 'root')
16
+ * @param columns - All columns from entityIR
17
+ * @param hidden - Array of hidden field names
18
+ */
19
+ function buildVisibleJsonb(alias: string, columns: ColumnInfo[], hidden: string[] = []): string {
20
+ if (!hidden || hidden.length === 0) {
21
+ return `to_jsonb(${alias}.*)`;
22
+ }
23
+
24
+ const visibleCols = columns.filter(c => !hidden.includes(c.name));
25
+ const pairs = visibleCols.map(c => `'${c.name}', ${alias}.${c.name}`).join(', ');
26
+ return `jsonb_build_object(${pairs})`;
27
+ }
28
+
29
+ export function generateCoreSQL() {
30
+ return `
31
+ -- DZQL V2 Core Schema
32
+ CREATE SCHEMA IF NOT EXISTS dzql_v2;
33
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
34
+
35
+ -- Migrations Table
36
+ CREATE TABLE IF NOT EXISTS dzql_v2.migrations (
37
+ id text PRIMARY KEY,
38
+ applied_at timestamptz DEFAULT now(),
39
+ checksum text NOT NULL,
40
+ name text NOT NULL
41
+ );
42
+
43
+ -- Events Table (Normalized Row Events)
44
+ CREATE TABLE IF NOT EXISTS dzql_v2.events (
45
+ id bigserial PRIMARY KEY,
46
+ commit_id bigint NOT NULL,
47
+ table_name text NOT NULL,
48
+ op text NOT NULL,
49
+ pk jsonb NOT NULL,
50
+ data jsonb,
51
+ old_data jsonb,
52
+ user_id int,
53
+ created_at timestamptz DEFAULT now()
54
+ );
55
+
56
+ -- Commit Sequence
57
+ CREATE SEQUENCE IF NOT EXISTS dzql_v2.commit_seq;
58
+
59
+ -- === AUTH FUNCTIONS ===
60
+
61
+ -- Register User
62
+ CREATE OR REPLACE FUNCTION dzql_v2.register_user(p_params jsonb)
63
+ RETURNS jsonb
64
+ LANGUAGE plpgsql
65
+ SECURITY DEFINER
66
+ SET search_path = dzql_v2, public
67
+ AS $$
68
+ DECLARE
69
+ v_user_id int;
70
+ v_email text;
71
+ v_password text;
72
+ v_name text;
73
+ v_options jsonb;
74
+ BEGIN
75
+ v_email := p_params->>'email';
76
+ v_password := p_params->>'password';
77
+ v_name := COALESCE(p_params->>'name', v_email);
78
+ v_options := COALESCE(p_params->'options', '{}'::jsonb);
79
+
80
+ IF v_email IS NULL OR v_password IS NULL THEN
81
+ RAISE EXCEPTION 'validation_error: email and password required';
82
+ END IF;
83
+
84
+ INSERT INTO users (email, password_hash, name)
85
+ VALUES (v_email, crypt(v_password, gen_salt('bf')), v_name)
86
+ RETURNING id INTO v_user_id;
87
+
88
+ -- TODO: Handle v_options if needed (e.g. creating orgs)
89
+
90
+ -- Return minimal profile (Token generation happens in Runtime layer)
91
+ RETURN jsonb_build_object(
92
+ 'user_id', v_user_id,
93
+ 'email', v_email,
94
+ 'name', v_name
95
+ );
96
+ END;
97
+ $$;
98
+
99
+ -- Login User
100
+ CREATE OR REPLACE FUNCTION dzql_v2.login_user(p_params jsonb)
101
+ RETURNS jsonb
102
+ LANGUAGE plpgsql
103
+ SECURITY DEFINER
104
+ SET search_path = dzql_v2, public
105
+ AS $$
106
+ DECLARE
107
+ v_user record;
108
+ BEGIN
109
+ SELECT * INTO v_user FROM users WHERE email = p_params->>'email';
110
+
111
+ IF v_user IS NULL OR v_user.password_hash != crypt(p_params->>'password', v_user.password_hash) THEN
112
+ RAISE EXCEPTION 'permission_denied: invalid credentials';
113
+ END IF;
114
+
115
+ RETURN jsonb_build_object(
116
+ 'user_id', v_user.id,
117
+ 'email', v_user.email,
118
+ 'name', v_user.name
119
+ );
120
+ END;
121
+ $$;
122
+ `;
123
+ }
124
+
125
+ export function generateSchemaSQL(name: string, entityIR: EntityIR): string {
126
+ const columns = entityIR.columns.map((c: ColumnInfo) => {
127
+ return `${c.name} ${c.type}`;
128
+ }).join(',\n ');
129
+
130
+ return `
131
+ CREATE TABLE IF NOT EXISTS ${name} (
132
+ ${columns}
133
+ );
134
+ `;
135
+ }
136
+
137
+ // === SAVE FUNCTION (Upsert) ===
138
+ export function generateSaveFunction(name: string, entityIR: EntityIR): string {
139
+ const cols = entityIR.columns.map((c: ColumnInfo) => c.name);
140
+ const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
141
+ const pk = pkFields[0]; // For backwards compatibility with single PK
142
+ const isCompositePK = pkFields.length > 1;
143
+ const fieldDefaults = entityIR.fieldDefaults || {};
144
+ const hidden = entityIR.hidden || [];
145
+
146
+ // Build INSERT columns/values
147
+ // Exclude serial columns from INSERT (let DB handle sequence)
148
+ const insertCols = entityIR.columns.filter((c: ColumnInfo) => {
149
+ const isSerial = c.type.toLowerCase().includes('serial');
150
+ return !isSerial;
151
+ });
152
+
153
+ const colList = insertCols.map((c: ColumnInfo) => c.name).join(', ');
154
+
155
+ // Build value list with field defaults support
156
+ const valList = insertCols.map((c: ColumnInfo) => {
157
+ let cast = '';
158
+ if (c.type.includes('int') || c.type.includes('serial')) cast = '::int';
159
+ else if (c.type.includes('timestamp')) cast = '::timestamptz';
160
+ else if (c.type.includes('date')) cast = '::date';
161
+ else if (c.type.includes('bool')) cast = '::boolean';
162
+ else if (c.type.includes('decimal') || c.type.includes('numeric')) cast = '::numeric';
163
+
164
+ const defaultValue = fieldDefaults[c.name];
165
+ if (defaultValue) {
166
+ // Apply field default if not provided in p_data
167
+ let defaultExpr: string;
168
+ if (defaultValue === '@user_id') {
169
+ defaultExpr = 'p_user_id';
170
+ } else if (defaultValue === '@now') {
171
+ defaultExpr = 'now()';
172
+ } else if (defaultValue === '@today') {
173
+ defaultExpr = 'current_date';
174
+ } else {
175
+ // Literal value
176
+ defaultExpr = `'${defaultValue}'${cast}`;
177
+ }
178
+ return `COALESCE((p_data->>'${c.name}')${cast}, ${defaultExpr})`;
179
+ }
180
+ return `(p_data->>'${c.name}')${cast}`;
181
+ }).join(', ');
182
+
183
+ // Build UPDATE SET clause (Partial Update) - exclude all PK fields
184
+ const updateSetClause = entityIR.columns
185
+ .filter((c: ColumnInfo) => !pkFields.includes(c.name))
186
+ .map((c: ColumnInfo) => {
187
+ let cast = '';
188
+ if (c.type.includes('int') || c.type.includes('serial')) cast = '::int';
189
+ else if (c.type.includes('timestamp')) cast = '::timestamptz';
190
+ else if (c.type.includes('date')) cast = '::date';
191
+ else if (c.type.includes('bool')) cast = '::boolean';
192
+ else if (c.type.includes('decimal') || c.type.includes('numeric')) cast = '::numeric';
193
+
194
+ return `${c.name} = CASE WHEN (p_data ? '${c.name}') THEN (p_data->>'${c.name}')${cast} ELSE ${c.name} END`;
195
+ })
196
+ .join(',\n ');
197
+
198
+ // Build composite PK handling
199
+ const pkExistsCheck = pkFields.map(f => {
200
+ const col = entityIR.columns.find((c: ColumnInfo) => c.name === f);
201
+ let cast = '::int';
202
+ if (col) {
203
+ if (col.type.includes('text') || col.type.includes('varchar')) cast = '';
204
+ else if (col.type.includes('date')) cast = '::date';
205
+ else if (col.type.includes('timestamp')) cast = '::timestamptz';
206
+ }
207
+ return `${f} = (p_data->>'${f}')${cast}`;
208
+ }).join(' AND ');
209
+
210
+ const pkWhereClause = pkExistsCheck;
211
+
212
+ // Build PK JSONB object for events (use -> to preserve type, not ->> which extracts as text)
213
+ const pkJsonbExpr = pkFields.length === 1
214
+ ? `jsonb_build_object('${pk}', v_result->'${pk}')`
215
+ : `jsonb_build_object(${pkFields.map(f => `'${f}', v_result->'${f}'`).join(', ')})`;
216
+
217
+ // Check if all PK fields are present
218
+ const pkNullCheck = pkFields.map(f => `(p_data->>'${f}') IS NOT NULL`).join(' AND ');
219
+
220
+ // Permissions & Graph Rules
221
+ const createPerm = entityIR.permissions?.create?.[0]
222
+ ? compilePermission(name, entityIR.permissions.create[0], null, 'p_data')
223
+ : 'TRUE';
224
+
225
+ const updatePerm = entityIR.permissions?.update?.[0]
226
+ ? compilePermission(name, entityIR.permissions.update[0], null, 'p_data')
227
+ : 'TRUE';
228
+
229
+ const onCreateRules = entityIR.graphRules?.onCreate
230
+ ? compileGraphRules(name, 'create', entityIR.graphRules.onCreate)
231
+ : '';
232
+
233
+ const onUpdateRules = entityIR.graphRules?.onUpdate
234
+ ? compileGraphRules(name, 'update', entityIR.graphRules.onUpdate)
235
+ : '';
236
+
237
+ // M2M Support
238
+ const m2m: Record<string, ManyToManyIR> = entityIR.manyToMany || {};
239
+ const m2mKeys = Object.keys(m2m);
240
+
241
+ // M2M variable declarations
242
+ const m2mVarDeclarations = m2mKeys.map(key => {
243
+ const config: ManyToManyIR = m2m[key];
244
+ return ` v_${config.idField} INT[];`;
245
+ }).join('\n');
246
+
247
+ // M2M extraction (remove from p_data before INSERT/UPDATE)
248
+ const m2mExtraction = m2mKeys.map(key => {
249
+ const config: ManyToManyIR = m2m[key];
250
+ return `
251
+ -- M2M: Extract ${key} IDs
252
+ IF p_data ? '${config.idField}' THEN
253
+ v_${config.idField} := ARRAY(SELECT jsonb_array_elements_text(p_data->'${config.idField}')::int);
254
+ p_data := p_data - '${config.idField}';
255
+ END IF;`;
256
+ }).join('\n');
257
+
258
+ // M2M sync (after INSERT/UPDATE) - uses first PK field for M2M local key
259
+ // Note: M2M typically uses a single local key (the entity's ID), not composite PK
260
+ const m2mSync = m2mKeys.map(key => {
261
+ const config: ManyToManyIR = m2m[key];
262
+ return `
263
+ -- M2M Sync: ${key} (junction: ${config.junctionTable})
264
+ IF v_${config.idField} IS NOT NULL THEN
265
+ -- Delete relationships not in new list
266
+ DELETE FROM ${config.junctionTable}
267
+ WHERE ${config.localKey} = (v_result->>'${pk}')::int
268
+ AND (${config.foreignKey} <> ALL(v_${config.idField}) OR v_${config.idField} = '{}');
269
+
270
+ -- Insert new relationships (idempotent)
271
+ IF array_length(v_${config.idField}, 1) > 0 THEN
272
+ INSERT INTO ${config.junctionTable} (${config.localKey}, ${config.foreignKey})
273
+ SELECT (v_result->>'${pk}')::int, unnest(v_${config.idField})
274
+ ON CONFLICT (${config.localKey}, ${config.foreignKey}) DO NOTHING;
275
+ END IF;
276
+ END IF;`;
277
+ }).join('\n');
278
+
279
+ // M2M expansion (add to output)
280
+ const m2mExpansion = m2mKeys.map(key => {
281
+ const config: ManyToManyIR = m2m[key];
282
+ let sql = `
283
+ -- M2M: Add ${config.idField} to output
284
+ v_result := v_result || jsonb_build_object('${config.idField}',
285
+ (SELECT COALESCE(jsonb_agg(${config.foreignKey} ORDER BY ${config.foreignKey}), '[]'::jsonb)
286
+ FROM ${config.junctionTable} WHERE ${config.localKey} = (v_result->>'${pk}')::int));`;
287
+
288
+ if (config.expand) {
289
+ sql += `
290
+ -- M2M: Add expanded ${key} to output
291
+ v_result := v_result || jsonb_build_object('${key}',
292
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
293
+ FROM ${config.junctionTable} jt
294
+ JOIN ${config.targetEntity} t ON t.id = jt.${config.foreignKey}
295
+ WHERE jt.${config.localKey} = (v_result->>'${pk}')::int));`;
296
+ }
297
+ return sql;
298
+ }).join('\n');
299
+
300
+ return `
301
+ CREATE OR REPLACE FUNCTION dzql_v2.save_${name}(p_user_id int, p_data jsonb)
302
+ RETURNS jsonb
303
+ LANGUAGE plpgsql
304
+ SECURITY DEFINER
305
+ SET search_path = dzql_v2, public
306
+ AS $$
307
+ DECLARE
308
+ v_result jsonb;
309
+ v_old_data jsonb;
310
+ v_commit_id bigint;
311
+ v_op text;
312
+ ${m2mVarDeclarations}
313
+ BEGIN
314
+ v_commit_id := nextval('dzql_v2.commit_seq');
315
+ ${m2mExtraction}
316
+
317
+ -- Determine Operation & Check Permissions (supports composite PK)
318
+ IF (${pkNullCheck}) AND EXISTS(SELECT 1 FROM ${name} WHERE ${pkWhereClause}) THEN
319
+ v_op := 'update';
320
+
321
+ -- Fetch old data for update rules/events
322
+ SELECT to_jsonb(${name}.*) INTO v_old_data FROM ${name} WHERE ${pkWhereClause};
323
+
324
+ IF NOT (${updatePerm}) THEN
325
+ RAISE EXCEPTION 'permission_denied';
326
+ END IF;
327
+
328
+ -- Perform Partial Update
329
+ UPDATE ${name} SET
330
+ ${updateSetClause}
331
+ WHERE ${pkWhereClause}
332
+ RETURNING to_jsonb(${name}.*) INTO v_result;
333
+
334
+ ${onUpdateRules}
335
+
336
+ ELSE
337
+ v_op := 'insert';
338
+ IF NOT (${createPerm}) THEN
339
+ RAISE EXCEPTION 'permission_denied';
340
+ END IF;
341
+
342
+ -- Perform Insert
343
+ INSERT INTO ${name} (${colList})
344
+ VALUES (${valList})
345
+ RETURNING to_jsonb(${name}.*) INTO v_result;
346
+
347
+ ${onCreateRules}
348
+ END IF;
349
+ ${m2mSync}
350
+ ${m2mExpansion}
351
+
352
+ -- Emit Event
353
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
354
+ VALUES (
355
+ v_commit_id,
356
+ '${name}',
357
+ v_op,
358
+ ${pkJsonbExpr},
359
+ v_result,
360
+ v_old_data, -- NULL for insert
361
+ p_user_id
362
+ );
363
+
364
+ -- Notify Runtime
365
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
366
+
367
+ -- Remove hidden fields before returning to client
368
+ RETURN ${hidden.length > 0 ? `v_result - ARRAY[${hidden.map(f => `'${f}'`).join(', ')}]` : 'v_result'};
369
+ END;
370
+ $$;
371
+ `;
372
+ }
373
+
374
+ // === DELETE FUNCTION (Cascade or Soft Delete) ===
375
+ export function generateDeleteFunction(name: string, entityIR: EntityIR): string {
376
+ const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
377
+ const pk = pkFields[0];
378
+ const softDelete = entityIR.softDelete || false;
379
+ const hidden = entityIR.hidden || [];
380
+
381
+ // Build composite PK handling
382
+ const pkWhereClause = pkFields.map(f => {
383
+ const col = entityIR.columns.find((c: ColumnInfo) => c.name === f);
384
+ let cast = '::int';
385
+ if (col) {
386
+ if (col.type.includes('text') || col.type.includes('varchar')) cast = '';
387
+ else if (col.type.includes('date')) cast = '::date';
388
+ else if (col.type.includes('timestamp')) cast = '::timestamptz';
389
+ }
390
+ return `${f} = (p_pk->>'${f}')${cast}`;
391
+ }).join(' AND ');
392
+
393
+ // Build PK JSONB object for events (use -> to preserve type, not ->> which extracts as text)
394
+ const pkJsonbExpr = pkFields.length === 1
395
+ ? `jsonb_build_object('${pk}', v_old_data->'${pk}')`
396
+ : `jsonb_build_object(${pkFields.map(f => `'${f}', v_old_data->'${f}'`).join(', ')})`;
397
+
398
+ // Permissions (Check against v_old_data)
399
+ const deletePerm = entityIR.permissions?.delete?.[0]
400
+ ? compilePermission(name, entityIR.permissions.delete[0], null, 'v_old_data')
401
+ : 'TRUE';
402
+
403
+ const onDeleteRules = entityIR.graphRules?.onDelete
404
+ ? compileGraphRules(name, 'delete', entityIR.graphRules.onDelete)
405
+ : '';
406
+
407
+ // Soft delete: UPDATE SET deleted_at = now() instead of DELETE
408
+ const deleteOperation = softDelete
409
+ ? `UPDATE ${name} SET deleted_at = now() WHERE ${pkWhereClause}`
410
+ : `DELETE FROM ${name} WHERE ${pkWhereClause}`;
411
+
412
+ return `
413
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_${name}(p_user_id int, p_pk jsonb)
414
+ RETURNS jsonb
415
+ LANGUAGE plpgsql
416
+ SECURITY DEFINER
417
+ SET search_path = dzql_v2, public
418
+ AS $$
419
+ DECLARE
420
+ v_old_data jsonb;
421
+ v_commit_id bigint;
422
+ BEGIN
423
+ v_commit_id := nextval('dzql_v2.commit_seq');
424
+
425
+ -- Fetch old data FIRST for permission check
426
+ SELECT to_jsonb(${name}.*) INTO v_old_data FROM ${name} WHERE ${pkWhereClause};
427
+
428
+ IF v_old_data IS NULL THEN
429
+ RAISE EXCEPTION 'not_found';
430
+ END IF;
431
+
432
+ -- Permission Check (Delete)
433
+ IF NOT (${deletePerm}) THEN
434
+ RAISE EXCEPTION 'permission_denied';
435
+ END IF;
436
+
437
+ -- Graph Rules (Pre-delete cascades)
438
+ ${onDeleteRules}
439
+
440
+ -- Perform ${softDelete ? 'Soft ' : ''}Delete
441
+ ${deleteOperation};
442
+
443
+ -- Emit Event (always 'delete' operation for client-side removal)
444
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
445
+ VALUES (
446
+ v_commit_id,
447
+ '${name}',
448
+ 'delete',
449
+ ${pkJsonbExpr},
450
+ v_old_data, -- Include full data for subscription resolution
451
+ v_old_data,
452
+ p_user_id
453
+ );
454
+
455
+ -- Notify Runtime
456
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
457
+
458
+ -- Remove hidden fields before returning to client
459
+ RETURN ${hidden.length > 0 ? `v_old_data - ARRAY[${hidden.map(f => `'${f}'`).join(', ')}]` : 'v_old_data'};
460
+ END;
461
+ $$;
462
+ `;
463
+ }
464
+
465
+ // === GET FUNCTION ===
466
+ export function generateGetFunction(name: string, entityIR: EntityIR): string {
467
+ const pkFields = entityIR.primaryKey.length > 0 ? entityIR.primaryKey : ['id'];
468
+ const pk = pkFields[0];
469
+ const hidden = entityIR.hidden || [];
470
+
471
+ // Build composite PK handling
472
+ const pkWhereClause = pkFields.map(f => {
473
+ const col = entityIR.columns.find((c: ColumnInfo) => c.name === f);
474
+ let cast = '::int';
475
+ if (col) {
476
+ if (col.type.includes('text') || col.type.includes('varchar')) cast = '';
477
+ else if (col.type.includes('date')) cast = '::date';
478
+ else if (col.type.includes('timestamp')) cast = '::timestamptz';
479
+ }
480
+ return `${f} = (p_pk->>'${f}')${cast}`;
481
+ }).join(' AND ');
482
+
483
+ const viewPerm = entityIR.permissions?.view?.length > 0
484
+ ? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
485
+ : 'TRUE';
486
+
487
+ // Build SELECT expression excluding hidden fields
488
+ const selectExpr = buildVisibleJsonb(name, entityIR.columns, hidden);
489
+
490
+ // M2M expansion for GET
491
+ const m2m: Record<string, ManyToManyIR> = entityIR.manyToMany || {};
492
+ const m2mKeys = Object.keys(m2m);
493
+
494
+ const m2mExpansion = m2mKeys.map(key => {
495
+ const config: ManyToManyIR = m2m[key];
496
+ let sql = `
497
+ -- M2M: Add ${config.idField} to result
498
+ v_result := v_result || jsonb_build_object('${config.idField}',
499
+ (SELECT COALESCE(jsonb_agg(${config.foreignKey} ORDER BY ${config.foreignKey}), '[]'::jsonb)
500
+ FROM ${config.junctionTable} WHERE ${config.localKey} = (v_result->>'${pk}')::int));`;
501
+
502
+ if (config.expand) {
503
+ sql += `
504
+ -- M2M: Add expanded ${key} to result
505
+ v_result := v_result || jsonb_build_object('${key}',
506
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
507
+ FROM ${config.junctionTable} jt
508
+ JOIN ${config.targetEntity} t ON t.id = jt.${config.foreignKey}
509
+ WHERE jt.${config.localKey} = (v_result->>'${pk}')::int));`;
510
+ }
511
+ return sql;
512
+ }).join('\n');
513
+
514
+ return `
515
+ CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(p_user_id int, p_pk jsonb)
516
+ RETURNS jsonb
517
+ LANGUAGE plpgsql
518
+ SECURITY DEFINER
519
+ SET search_path = dzql_v2, public
520
+ AS $$
521
+ DECLARE
522
+ v_result jsonb;
523
+ BEGIN
524
+ SELECT ${selectExpr} INTO v_result
525
+ FROM ${name}
526
+ WHERE ${pkWhereClause}
527
+ AND (${viewPerm});
528
+
529
+ IF v_result IS NULL THEN
530
+ RETURN NULL;
531
+ END IF;
532
+ ${m2mExpansion}
533
+
534
+ RETURN v_result;
535
+ END;
536
+ $$;
537
+ `;
538
+ }
539
+
540
+ // === SEARCH FUNCTION ===
541
+ export function generateSearchFunction(name: string, entityIR: EntityIR): string {
542
+ const pk = entityIR.primaryKey[0] || 'id';
543
+ const softDelete = entityIR.softDelete || false;
544
+ const hidden = entityIR.hidden || [];
545
+
546
+ const viewPerm = entityIR.permissions?.view?.length > 0
547
+ ? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
548
+ : 'TRUE';
549
+
550
+ // Soft delete filter - exclude deleted records from search
551
+ const softDeleteFilter = softDelete ? ' AND deleted_at IS NULL' : '';
552
+
553
+ // Get the label field for default sorting
554
+ const labelField = entityIR.labelField || 'id';
555
+
556
+ // M2M expansion for SEARCH using LATERAL joins
557
+ const m2m: Record<string, ManyToManyIR> = entityIR.manyToMany || {};
558
+ const m2mKeys = Object.keys(m2m);
559
+
560
+ // Build LATERAL joins for each M2M relationship
561
+ const m2mLateralJoins = m2mKeys.map(key => {
562
+ const config: ManyToManyIR = m2m[key];
563
+ let sql = `
564
+ LEFT JOIN LATERAL (
565
+ SELECT COALESCE(jsonb_agg(${config.foreignKey} ORDER BY ${config.foreignKey}), ''[]''::jsonb) as ${config.idField}
566
+ FROM ${config.junctionTable}
567
+ WHERE ${config.localKey} = t.${pk}
568
+ ) m2m_${config.idField} ON true`;
569
+
570
+ if (config.expand) {
571
+ sql += `
572
+ LEFT JOIN LATERAL (
573
+ SELECT COALESCE(jsonb_agg(to_jsonb(target.*) ORDER BY target.id), ''[]''::jsonb) as ${key}
574
+ FROM ${config.junctionTable} jt
575
+ JOIN ${config.targetEntity} target ON target.id = jt.${config.foreignKey}
576
+ WHERE jt.${config.localKey} = t.${pk}
577
+ ) m2m_${key} ON true`;
578
+ }
579
+ return sql;
580
+ }).join('');
581
+
582
+ // Build base SELECT expression excluding hidden fields (escape single quotes for dynamic SQL)
583
+ const baseSelectExpr = buildVisibleJsonb('t', entityIR.columns, hidden).replace(/'/g, "''");
584
+
585
+ // Build SELECT expression that merges M2M fields
586
+ const m2mSelectMerge = m2mKeys.map(key => {
587
+ const config = m2m[key];
588
+ let merge = ` || jsonb_build_object(''${config.idField}'', m2m_${config.idField}.${config.idField})`;
589
+ if (config.expand) {
590
+ merge += ` || jsonb_build_object(''${key}'', m2m_${key}.${key})`;
591
+ }
592
+ return merge;
593
+ }).join('');
594
+
595
+ const selectExpr = m2mKeys.length > 0
596
+ ? `${baseSelectExpr}${m2mSelectMerge}`
597
+ : baseSelectExpr;
598
+
599
+ return `
600
+ CREATE OR REPLACE FUNCTION dzql_v2.search_${name}(p_user_id int, p_query jsonb)
601
+ RETURNS jsonb
602
+ LANGUAGE plpgsql
603
+ SECURITY DEFINER
604
+ SET search_path = dzql_v2, public
605
+ AS $$
606
+ DECLARE
607
+ v_results jsonb;
608
+ v_filters jsonb;
609
+ v_sort_field text;
610
+ v_sort_order text;
611
+ v_where_clause text := '';
612
+ v_field text;
613
+ v_filter jsonb;
614
+ v_operator text;
615
+ v_value jsonb;
616
+ BEGIN
617
+ -- Extract query parameters
618
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
619
+ v_sort_field := COALESCE(p_query->>'sort_field', '${labelField}');
620
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
621
+
622
+ -- Build WHERE clause from filters
623
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
624
+ LOOP
625
+ -- Handle simple value (exact match)
626
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
627
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
628
+ ELSE
629
+ -- Handle operator-based filters
630
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
631
+ LOOP
632
+ CASE v_operator
633
+ WHEN 'eq' THEN
634
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
635
+ WHEN 'ne' THEN
636
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
637
+ WHEN 'gt' THEN
638
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
639
+ WHEN 'gte' THEN
640
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
641
+ WHEN 'lt' THEN
642
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
643
+ WHEN 'lte' THEN
644
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
645
+ WHEN 'in' THEN
646
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
647
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
648
+ WHEN 'not_in' THEN
649
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
650
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
651
+ WHEN 'like' THEN
652
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
653
+ WHEN 'ilike' THEN
654
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
655
+ WHEN 'is_null' THEN
656
+ IF (v_value::text = 'true') THEN
657
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
658
+ END IF;
659
+ WHEN 'not_null' THEN
660
+ IF (v_value::text = 'true') THEN
661
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
662
+ END IF;
663
+ ELSE
664
+ -- Unknown operator, skip
665
+ END CASE;
666
+ END LOOP;
667
+ END IF;
668
+ END LOOP;
669
+
670
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
671
+ EXECUTE format('
672
+ SELECT COALESCE(jsonb_agg(${selectExpr}), ''[]''::jsonb)
673
+ FROM (
674
+ SELECT * FROM ${name}
675
+ WHERE (${viewPerm})${softDeleteFilter} %s
676
+ ORDER BY %I %s
677
+ LIMIT %L OFFSET %L
678
+ ) t${m2mLateralJoins}
679
+ ', v_where_clause, v_sort_field, v_sort_order,
680
+ COALESCE((p_query->>'limit')::int, 10),
681
+ COALESCE((p_query->>'offset')::int, 0))
682
+ INTO v_results;
683
+
684
+ RETURN v_results;
685
+ END;
686
+ $$;
687
+ `;
688
+ }
689
+
690
+ // === AGGREGATE GENERATOR ===
691
+ export function generateEntitySQL(name: string, entityIR: EntityIR): string {
692
+ return [
693
+ generateSaveFunction(name, entityIR),
694
+ generateDeleteFunction(name, entityIR),
695
+ generateGetFunction(name, entityIR),
696
+ generateSearchFunction(name, entityIR)
697
+ ].join('\n');
698
+ }