dzql 0.5.33 → 0.6.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 (150) 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 +293 -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 +641 -0
  20. package/docs/project-setup.md +432 -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 +164 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/create/.env.example +8 -0
  42. package/src/create/README.md +101 -0
  43. package/src/create/compose.yml +14 -0
  44. package/src/create/domain.ts +153 -0
  45. package/src/create/package.json +24 -0
  46. package/src/create/server.ts +18 -0
  47. package/src/create/setup.sh +11 -0
  48. package/src/create/tsconfig.json +15 -0
  49. package/src/runtime/auth.ts +39 -0
  50. package/src/runtime/db.ts +33 -0
  51. package/src/runtime/errors.ts +51 -0
  52. package/src/runtime/index.ts +98 -0
  53. package/src/runtime/js_functions.ts +63 -0
  54. package/src/runtime/manifest_loader.ts +29 -0
  55. package/src/runtime/namespace.ts +483 -0
  56. package/src/runtime/server.ts +87 -0
  57. package/src/runtime/ws.ts +197 -0
  58. package/src/shared/ir.ts +197 -0
  59. package/tests/client.test.ts +38 -0
  60. package/tests/codegen.test.ts +71 -0
  61. package/tests/compiler.test.ts +45 -0
  62. package/tests/graph_rules.test.ts +173 -0
  63. package/tests/integration/db.test.ts +174 -0
  64. package/tests/integration/e2e.test.ts +65 -0
  65. package/tests/integration/features.test.ts +922 -0
  66. package/tests/integration/full_stack.test.ts +262 -0
  67. package/tests/integration/setup.ts +45 -0
  68. package/tests/ir.test.ts +32 -0
  69. package/tests/namespace.test.ts +395 -0
  70. package/tests/permissions.test.ts +55 -0
  71. package/tests/pinia.test.ts +48 -0
  72. package/tests/realtime.test.ts +22 -0
  73. package/tests/runtime.test.ts +80 -0
  74. package/tests/subscribable_gen.test.ts +72 -0
  75. package/tests/subscribable_reactivity.test.ts +258 -0
  76. package/tests/venues_gen.test.ts +25 -0
  77. package/tsconfig.json +20 -0
  78. package/tsconfig.tsbuildinfo +1 -0
  79. package/README.md +0 -90
  80. package/bin/cli.js +0 -727
  81. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  82. package/docs/compiler/CODING_STANDARDS.md +0 -415
  83. package/docs/compiler/COMPARISON.md +0 -673
  84. package/docs/compiler/QUICKSTART.md +0 -326
  85. package/docs/compiler/README.md +0 -134
  86. package/docs/examples/README.md +0 -38
  87. package/docs/examples/blog.sql +0 -160
  88. package/docs/examples/venue-detail-simple.sql +0 -8
  89. package/docs/examples/venue-detail-subscribable.sql +0 -45
  90. package/docs/for-ai/claude-guide.md +0 -1210
  91. package/docs/getting-started/quickstart.md +0 -125
  92. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  93. package/docs/getting-started/tutorial.md +0 -1104
  94. package/docs/guides/atomic-updates.md +0 -299
  95. package/docs/guides/client-stores.md +0 -730
  96. package/docs/guides/composite-primary-keys.md +0 -158
  97. package/docs/guides/custom-functions.md +0 -362
  98. package/docs/guides/drop-semantics.md +0 -554
  99. package/docs/guides/field-defaults.md +0 -240
  100. package/docs/guides/interpreter-vs-compiler.md +0 -237
  101. package/docs/guides/many-to-many.md +0 -929
  102. package/docs/guides/subscriptions.md +0 -537
  103. package/docs/reference/api.md +0 -1373
  104. package/docs/reference/client.md +0 -224
  105. package/src/client/stores/index.js +0 -8
  106. package/src/client/stores/useAppStore.js +0 -285
  107. package/src/client/stores/useWsStore.js +0 -289
  108. package/src/client/ws.js +0 -762
  109. package/src/compiler/cli/compile-example.js +0 -33
  110. package/src/compiler/cli/compile-subscribable.js +0 -43
  111. package/src/compiler/cli/debug-compile.js +0 -44
  112. package/src/compiler/cli/debug-parse.js +0 -26
  113. package/src/compiler/cli/debug-path-parser.js +0 -18
  114. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  115. package/src/compiler/cli/index.js +0 -174
  116. package/src/compiler/codegen/auth-codegen.js +0 -153
  117. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  118. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  119. package/src/compiler/codegen/notification-codegen.js +0 -232
  120. package/src/compiler/codegen/operation-codegen.js +0 -1382
  121. package/src/compiler/codegen/permission-codegen.js +0 -318
  122. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  123. package/src/compiler/compiler.js +0 -371
  124. package/src/compiler/index.js +0 -11
  125. package/src/compiler/parser/entity-parser.js +0 -440
  126. package/src/compiler/parser/path-parser.js +0 -290
  127. package/src/compiler/parser/subscribable-parser.js +0 -244
  128. package/src/database/dzql-core.sql +0 -161
  129. package/src/database/migrations/001_schema.sql +0 -60
  130. package/src/database/migrations/002_functions.sql +0 -890
  131. package/src/database/migrations/003_operations.sql +0 -1135
  132. package/src/database/migrations/004_search.sql +0 -581
  133. package/src/database/migrations/005_entities.sql +0 -730
  134. package/src/database/migrations/006_auth.sql +0 -94
  135. package/src/database/migrations/007_events.sql +0 -133
  136. package/src/database/migrations/008_hello.sql +0 -18
  137. package/src/database/migrations/008a_meta.sql +0 -172
  138. package/src/database/migrations/009_subscriptions.sql +0 -240
  139. package/src/database/migrations/010_atomic_updates.sql +0 -157
  140. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  141. package/src/index.js +0 -40
  142. package/src/server/api.js +0 -9
  143. package/src/server/db.js +0 -442
  144. package/src/server/index.js +0 -317
  145. package/src/server/logger.js +0 -259
  146. package/src/server/mcp.js +0 -594
  147. package/src/server/meta-route.js +0 -251
  148. package/src/server/namespace.js +0 -292
  149. package/src/server/subscriptions.js +0 -351
  150. 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
+ }