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,922 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
+ import { V2TestDatabase } from "./setup.js";
3
+ import { generateIR } from "../../src/cli/compiler/ir.js";
4
+ import { generateSearchFunction, generateSaveFunction, generateGetFunction } from "../../src/cli/codegen/sql.js";
5
+ import { compilePermission } from "../../src/cli/compiler/permissions.js";
6
+ import { handleRequest } from "../../src/runtime/server.js";
7
+ import { registerJsFunction, clearJsFunctions } from "../../src/runtime/js_functions.js";
8
+ import { loadManifest } from "../../src/runtime/manifest_loader.js";
9
+
10
+ // Import venues domain for IR generation
11
+ import { entities } from "../../examples/venues.js";
12
+ const venuesDomain = { entities, subscribables: {} };
13
+
14
+ describe("Feature Tests: Search Filters, Deep Paths, M2M", () => {
15
+ let db: V2TestDatabase;
16
+ let sql: any;
17
+ let ir: any;
18
+
19
+ beforeAll(async () => {
20
+ db = new V2TestDatabase();
21
+ sql = await db.setup();
22
+ ir = generateIR(venuesDomain);
23
+
24
+ // Apply core schema
25
+ await sql`CREATE SCHEMA IF NOT EXISTS dzql_v2`;
26
+ await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
27
+ await sql`CREATE SEQUENCE IF NOT EXISTS dzql_v2.commit_seq`;
28
+ await sql`
29
+ CREATE TABLE IF NOT EXISTS dzql_v2.events (
30
+ id bigserial PRIMARY KEY,
31
+ commit_id bigint NOT NULL,
32
+ table_name text NOT NULL,
33
+ op text NOT NULL,
34
+ pk jsonb NOT NULL,
35
+ data jsonb,
36
+ old_data jsonb,
37
+ user_id int,
38
+ created_at timestamptz DEFAULT now()
39
+ )
40
+ `;
41
+
42
+ // Create test tables
43
+ await sql`
44
+ CREATE TABLE IF NOT EXISTS users (
45
+ id serial PRIMARY KEY,
46
+ name text NOT NULL,
47
+ email text UNIQUE NOT NULL,
48
+ password_hash text NOT NULL,
49
+ created_at timestamptz DEFAULT now()
50
+ )
51
+ `;
52
+
53
+ await sql`
54
+ CREATE TABLE IF NOT EXISTS organisations (
55
+ id serial PRIMARY KEY,
56
+ name text UNIQUE NOT NULL,
57
+ description text
58
+ )
59
+ `;
60
+
61
+ await sql`
62
+ CREATE TABLE IF NOT EXISTS acts_for (
63
+ user_id int NOT NULL REFERENCES users(id),
64
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
65
+ valid_from date NOT NULL DEFAULT current_date,
66
+ valid_to date,
67
+ active boolean DEFAULT true,
68
+ PRIMARY KEY (user_id, org_id, valid_from)
69
+ )
70
+ `;
71
+
72
+ await sql`
73
+ CREATE TABLE IF NOT EXISTS venues (
74
+ id serial PRIMARY KEY,
75
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
76
+ name text UNIQUE NOT NULL,
77
+ address text NOT NULL,
78
+ description text
79
+ )
80
+ `;
81
+
82
+ await sql`
83
+ CREATE TABLE IF NOT EXISTS sites (
84
+ id serial PRIMARY KEY,
85
+ venue_id int NOT NULL REFERENCES venues(id),
86
+ name text NOT NULL,
87
+ description text
88
+ )
89
+ `;
90
+
91
+ await sql`
92
+ CREATE TABLE IF NOT EXISTS tags (
93
+ id serial PRIMARY KEY,
94
+ name text NOT NULL UNIQUE,
95
+ color text,
96
+ description text
97
+ )
98
+ `;
99
+
100
+ await sql`
101
+ CREATE TABLE IF NOT EXISTS brands (
102
+ id serial PRIMARY KEY,
103
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
104
+ name text NOT NULL,
105
+ description text,
106
+ UNIQUE(org_id, name)
107
+ )
108
+ `;
109
+
110
+ await sql`
111
+ CREATE TABLE IF NOT EXISTS brand_tags (
112
+ brand_id int NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
113
+ tag_id int NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
114
+ PRIMARY KEY (brand_id, tag_id)
115
+ )
116
+ `;
117
+
118
+ // Seed test data
119
+ await sql`INSERT INTO users (name, email, password_hash) VALUES ('Test User', 'test@example.com', 'hash')`;
120
+ await sql`INSERT INTO organisations (name) VALUES ('Org A'), ('Org B'), ('Org C')`;
121
+ await sql`INSERT INTO acts_for (user_id, org_id, active) VALUES (1, 1, true), (1, 2, true)`;
122
+ await sql`INSERT INTO venues (org_id, name, address) VALUES (1, 'Venue A', '123 St'), (1, 'Venue B', '456 Ave'), (2, 'Venue C', '789 Blvd')`;
123
+ await sql`INSERT INTO sites (venue_id, name) VALUES (1, 'Site 1'), (1, 'Site 2'), (2, 'Site 3')`;
124
+ await sql`INSERT INTO tags (name, color) VALUES ('Tag1', 'red'), ('Tag2', 'blue'), ('Tag3', 'green')`;
125
+ await sql`INSERT INTO brands (org_id, name) VALUES (1, 'Brand A')`;
126
+ await sql`INSERT INTO brand_tags (brand_id, tag_id) VALUES (1, 1), (1, 2)`;
127
+ });
128
+
129
+ afterAll(async () => {
130
+ await db.teardown();
131
+ });
132
+
133
+ // ============================================================
134
+ // SEARCH FILTERS TESTS
135
+ // ============================================================
136
+
137
+ describe("Search Filters", () => {
138
+ beforeAll(async () => {
139
+ // Generate and apply search function for venues
140
+ const venuesSearchSQL = generateSearchFunction("venues", ir.entities.venues);
141
+ await sql.unsafe(venuesSearchSQL);
142
+ });
143
+
144
+ test("simple filter (exact match)", async () => {
145
+ const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"org_id": 1}}'::jsonb)`;
146
+ const venues = result[0].search_venues;
147
+ expect(venues.length).toBe(2);
148
+ expect(venues.every((v: any) => v.org_id === 1)).toBe(true);
149
+ });
150
+
151
+ test("'in' operator with integer array", async () => {
152
+ const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"id": {"in": [1, 2]}}}'::jsonb)`;
153
+ const venues = result[0].search_venues;
154
+ expect(venues.length).toBe(2);
155
+ expect(venues.map((v: any) => v.id).sort()).toEqual([1, 2]);
156
+ });
157
+
158
+ test("'in' operator with FK column", async () => {
159
+ const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"org_id": {"in": [1, 2]}}}'::jsonb)`;
160
+ const venues = result[0].search_venues;
161
+ expect(venues.length).toBe(3);
162
+ });
163
+
164
+ test("'not_in' operator", async () => {
165
+ const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"id": {"not_in": [1, 2]}}}'::jsonb)`;
166
+ const venues = result[0].search_venues;
167
+ expect(venues.length).toBe(1);
168
+ expect(venues[0].id).toBe(3);
169
+ });
170
+
171
+ test("'ilike' operator for text search", async () => {
172
+ const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"name": {"ilike": "%venue%"}}}'::jsonb)`;
173
+ const venues = result[0].search_venues;
174
+ expect(venues.length).toBe(3);
175
+ });
176
+
177
+ test("'gt' and 'lt' operators", async () => {
178
+ const result = await sql`SELECT dzql_v2.search_venues(1, '{"filters": {"id": {"gt": 1, "lt": 3}}}'::jsonb)`;
179
+ const venues = result[0].search_venues;
180
+ expect(venues.length).toBe(1);
181
+ expect(venues[0].id).toBe(2);
182
+ });
183
+
184
+ test("sorting and pagination", async () => {
185
+ const result = await sql`SELECT dzql_v2.search_venues(1, '{"sort_field": "name", "sort_order": "desc", "limit": 2}'::jsonb)`;
186
+ const venues = result[0].search_venues;
187
+ expect(venues.length).toBe(2);
188
+ expect(venues[0].name).toBe("Venue C");
189
+ expect(venues[1].name).toBe("Venue B");
190
+ });
191
+ });
192
+
193
+ // ============================================================
194
+ // DEEP PERMISSION PATHS TESTS
195
+ // ============================================================
196
+
197
+ describe("Deep Permission Paths", () => {
198
+
199
+ test("single-hop path compiles correctly", () => {
200
+ const rule = "@org_id->acts_for[org_id=$]{active}.user_id";
201
+ const sql = compilePermission("venues", rule, null, "p_data");
202
+
203
+ expect(sql).toContain("EXISTS");
204
+ expect(sql).toContain("acts_for");
205
+ expect(sql).toContain("acts_for.org_id = (p_data->>'org_id')::int");
206
+ expect(sql).toContain("acts_for.active = true");
207
+ expect(sql).toContain("acts_for.user_id = p_user_id");
208
+ });
209
+
210
+ test("two-hop path compiles with nested subquery", () => {
211
+ const rule = "@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id";
212
+ const sql = compilePermission("sites", rule, null, "p_data");
213
+
214
+ expect(sql).toContain("EXISTS");
215
+ expect(sql).toContain("(SELECT org_id FROM venues WHERE id = (p_data->>'venue_id')::int)");
216
+ expect(sql).toContain("acts_for.active = true");
217
+ expect(sql).toContain("acts_for.user_id = p_user_id");
218
+ });
219
+
220
+ test("three-hop path compiles with double nested subquery", () => {
221
+ const rule = "@site_id->sites.venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id";
222
+ const sql = compilePermission("allocations", rule, null, "p_data");
223
+
224
+ expect(sql).toContain("EXISTS");
225
+ expect(sql).toContain("(SELECT venue_id FROM sites WHERE id = (p_data->>'site_id')::int)");
226
+ expect(sql).toContain("(SELECT org_id FROM venues WHERE id =");
227
+ expect(sql).toContain("acts_for.user_id = p_user_id");
228
+ });
229
+
230
+ test("table-first pattern compiles correctly", () => {
231
+ const rule = "contractor_rights[package_id=@package_id]{active}.contractor_org_id->acts_for[org_id=$]{active}.user_id";
232
+ const sql = compilePermission("allocations", rule, null, "p_data");
233
+
234
+ expect(sql).toContain("EXISTS");
235
+ expect(sql).toContain("SELECT contractor_org_id FROM contractor_rights");
236
+ expect(sql).toContain("contractor_rights.package_id = (p_data->>'package_id')::int");
237
+ expect(sql).toContain("contractor_rights.active = true");
238
+ });
239
+
240
+ test("table column access (non-JSONB) compiles correctly", () => {
241
+ const rule = "@org_id->acts_for[org_id=$]{active}.user_id";
242
+ const sql = compilePermission("venues", rule, null, "venues");
243
+
244
+ // Should use table.column syntax, not jsonb
245
+ expect(sql).toContain("acts_for.org_id = venues.org_id");
246
+ expect(sql).not.toContain("->>");
247
+ });
248
+
249
+ test("deep path works in actual SQL execution", async () => {
250
+ // Apply sites functions with deep permission path
251
+ const sitesIR = ir.entities.sites;
252
+ const sitesGetSQL = generateGetFunction("sites", sitesIR);
253
+ await sql.unsafe(sitesGetSQL);
254
+
255
+ // User 1 has access to org 1, which owns venue 1, which has sites 1 and 2
256
+ const result = await sql`SELECT dzql_v2.get_sites(1, '{"id": 1}'::jsonb)`;
257
+ // The permission check uses deep path: @venue_id->venues.org_id->acts_for
258
+ // Since we're using TRUE for view perms in the generated SQL, this should succeed
259
+ expect(result[0].get_sites).toBeDefined();
260
+ });
261
+ });
262
+
263
+ // ============================================================
264
+ // M2M RELATIONSHIPS TESTS
265
+ // ============================================================
266
+
267
+ describe("M2M Relationships", () => {
268
+ beforeAll(async () => {
269
+ // Generate and apply brands functions with M2M
270
+ const brandsIR = ir.entities.brands;
271
+ const brandsSaveSQL = generateSaveFunction("brands", brandsIR);
272
+ const brandsGetSQL = generateGetFunction("brands", brandsIR);
273
+ const brandsSearchSQL = generateSearchFunction("brands", brandsIR);
274
+
275
+ await sql.unsafe(brandsSaveSQL);
276
+ await sql.unsafe(brandsGetSQL);
277
+ await sql.unsafe(brandsSearchSQL);
278
+ });
279
+
280
+ test("M2M expansion in GET includes tag_ids array", async () => {
281
+ const result = await sql`SELECT dzql_v2.get_brands(1, '{"id": 1}'::jsonb)`;
282
+ const brand = result[0].get_brands;
283
+
284
+ expect(brand).toBeDefined();
285
+ expect(brand.tag_ids).toBeDefined();
286
+ expect(Array.isArray(brand.tag_ids)).toBe(true);
287
+ expect(brand.tag_ids.sort()).toEqual([1, 2]);
288
+ });
289
+
290
+ test("M2M sync on SAVE adds new relationships", async () => {
291
+ // Create new brand with tags
292
+ const result = await sql`SELECT dzql_v2.save_brands(1, '{"org_id": 1, "name": "New Brand", "tag_ids": [2, 3]}'::jsonb)`;
293
+ const brand = result[0].save_brands;
294
+
295
+ expect(brand.tag_ids).toBeDefined();
296
+ expect(brand.tag_ids.sort()).toEqual([2, 3]);
297
+
298
+ // Verify junction table
299
+ const junctions = await sql`SELECT tag_id FROM brand_tags WHERE brand_id = ${brand.id} ORDER BY tag_id`;
300
+ expect(junctions.map((j: any) => j.tag_id)).toEqual([2, 3]);
301
+ });
302
+
303
+ test("M2M sync on SAVE updates relationships", async () => {
304
+ // Get brand 1, change tags from [1, 2] to [1, 3]
305
+ // Note: org_id must be included for permission check on update
306
+ const result = await sql`SELECT dzql_v2.save_brands(1, '{"id": 1, "org_id": 1, "tag_ids": [1, 3]}'::jsonb)`;
307
+ const brand = result[0].save_brands;
308
+
309
+ expect(brand.tag_ids.sort()).toEqual([1, 3]);
310
+
311
+ // Verify junction table - tag 2 should be removed, tag 3 added
312
+ const junctions = await sql`SELECT tag_id FROM brand_tags WHERE brand_id = 1 ORDER BY tag_id`;
313
+ expect(junctions.map((j: any) => j.tag_id)).toEqual([1, 3]);
314
+ });
315
+
316
+ test("M2M sync with empty array removes all relationships", async () => {
317
+ // Create a brand with tags, then clear them
318
+ await sql`INSERT INTO brands (org_id, name) VALUES (1, 'Temp Brand') RETURNING id`;
319
+ const tempBrand = await sql`SELECT id FROM brands WHERE name = 'Temp Brand'`;
320
+ const tempId = tempBrand[0].id;
321
+ await sql`INSERT INTO brand_tags (brand_id, tag_id) VALUES (${tempId}, 1), (${tempId}, 2)`;
322
+
323
+ // Clear tags - org_id required for permission check
324
+ const payload = JSON.stringify({ id: tempId, org_id: 1, tag_ids: [] });
325
+ const result = await sql.unsafe(`SELECT dzql_v2.save_brands(1, '${payload}'::jsonb)`);
326
+ const brand = result[0].save_brands;
327
+
328
+ expect(brand.tag_ids).toEqual([]);
329
+
330
+ // Verify junction table is empty
331
+ const junctions = await sql`SELECT tag_id FROM brand_tags WHERE brand_id = ${tempId}`;
332
+ expect(junctions.length).toBe(0);
333
+ });
334
+
335
+ test("M2M expansion in SEARCH includes tag_ids for each result", async () => {
336
+ const result = await sql`SELECT dzql_v2.search_brands(1, '{"filters": {"org_id": 1}}'::jsonb)`;
337
+ const brands = result[0].search_brands;
338
+
339
+ expect(brands.length).toBeGreaterThan(0);
340
+ brands.forEach((brand: any) => {
341
+ expect(brand.tag_ids).toBeDefined();
342
+ expect(Array.isArray(brand.tag_ids)).toBe(true);
343
+ });
344
+ });
345
+ });
346
+
347
+ // ============================================================
348
+ // SOFT DELETE TESTS
349
+ // ============================================================
350
+
351
+ describe("Soft Delete", () => {
352
+ beforeAll(async () => {
353
+ // Create products table with soft delete support
354
+ await sql`
355
+ CREATE TABLE IF NOT EXISTS products (
356
+ id serial PRIMARY KEY,
357
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
358
+ name text NOT NULL,
359
+ description text,
360
+ price decimal(10, 2) NOT NULL DEFAULT 0.00,
361
+ created_by int REFERENCES users(id),
362
+ created_at timestamptz,
363
+ deleted_at timestamptz
364
+ )
365
+ `;
366
+
367
+ // Generate and apply products functions with soft delete
368
+ const productsIR = ir.entities.products;
369
+ const productsSaveSQL = generateSaveFunction("products", productsIR);
370
+ const productsGetSQL = generateGetFunction("products", productsIR);
371
+ const productsSearchSQL = generateSearchFunction("products", productsIR);
372
+
373
+ await sql.unsafe(productsSaveSQL);
374
+ await sql.unsafe(productsGetSQL);
375
+ await sql.unsafe(productsSearchSQL);
376
+
377
+ // Need to create delete function separately since it's not in generateEntitySQL individually
378
+ const { generateDeleteFunction } = await import("../../src/cli/codegen/sql.js");
379
+ const productsDeleteSQL = generateDeleteFunction("products", productsIR);
380
+ await sql.unsafe(productsDeleteSQL);
381
+ });
382
+
383
+ test("Soft delete sets deleted_at timestamp instead of removing row", async () => {
384
+ // Create a product
385
+ const created = await sql`
386
+ SELECT dzql_v2.save_products(1, ${sql.json({
387
+ org_id: 1,
388
+ name: "To Be Deleted",
389
+ price: 9.99
390
+ })}) as product
391
+ `;
392
+ const productId = created[0].product.id;
393
+
394
+ // Delete it
395
+ await sql`SELECT dzql_v2.delete_products(1, ${sql.json({ id: productId })})`;
396
+
397
+ // Check database - row should still exist with deleted_at set
398
+ const check = await sql`SELECT * FROM products WHERE id = ${productId}`;
399
+ expect(check.length).toBe(1);
400
+ expect(check[0].deleted_at).not.toBeNull();
401
+ });
402
+
403
+ test("Soft deleted records excluded from SEARCH", async () => {
404
+ // Create two products
405
+ const active = await sql`
406
+ SELECT dzql_v2.save_products(1, ${sql.json({
407
+ org_id: 1,
408
+ name: "Active Product",
409
+ price: 19.99
410
+ })}) as product
411
+ `;
412
+
413
+ const toDelete = await sql`
414
+ SELECT dzql_v2.save_products(1, ${sql.json({
415
+ org_id: 1,
416
+ name: "Product To Delete",
417
+ price: 29.99
418
+ })}) as product
419
+ `;
420
+ const deletedId = toDelete[0].product.id;
421
+
422
+ // Delete one
423
+ await sql`SELECT dzql_v2.delete_products(1, ${sql.json({ id: deletedId })})`;
424
+
425
+ // Search should not return deleted
426
+ const search = await sql`SELECT dzql_v2.search_products(1, '{"filters": {"org_id": 1}}'::jsonb)`;
427
+ const products = search[0].search_products;
428
+ const productIds = products.map((p: any) => p.id);
429
+ expect(productIds).not.toContain(deletedId);
430
+ });
431
+
432
+ test("Can still GET soft deleted record by ID (for audit)", async () => {
433
+ const created = await sql`
434
+ SELECT dzql_v2.save_products(1, ${sql.json({
435
+ org_id: 1,
436
+ name: "Audit Product",
437
+ price: 39.99
438
+ })}) as product
439
+ `;
440
+ const productId = created[0].product.id;
441
+
442
+ // Delete it
443
+ await sql`SELECT dzql_v2.delete_products(1, ${sql.json({ id: productId })})`;
444
+
445
+ // Should still be able to get it
446
+ const fetched = await sql`SELECT dzql_v2.get_products(1, ${sql.json({ id: productId })})`;
447
+ expect(fetched[0].get_products).not.toBeNull();
448
+ expect(fetched[0].get_products.deleted_at).not.toBeNull();
449
+ });
450
+ });
451
+
452
+ // ============================================================
453
+ // FIELD DEFAULTS TESTS
454
+ // ============================================================
455
+
456
+ describe("Field Defaults", () => {
457
+ test("@user_id default resolves to current user", async () => {
458
+ const product = await sql`
459
+ SELECT dzql_v2.save_products(1, ${sql.json({
460
+ org_id: 1,
461
+ name: "Default User Product",
462
+ price: 49.99
463
+ })}) as product
464
+ `;
465
+
466
+ expect(product[0].product.created_by).toBe(1);
467
+ });
468
+
469
+ test("@now default resolves to current timestamp", async () => {
470
+ const before = new Date();
471
+
472
+ const product = await sql`
473
+ SELECT dzql_v2.save_products(1, ${sql.json({
474
+ org_id: 1,
475
+ name: "Default Timestamp Product",
476
+ price: 59.99
477
+ })}) as product
478
+ `;
479
+
480
+ const after = new Date();
481
+ const createdAt = new Date(product[0].product.created_at);
482
+
483
+ expect(createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
484
+ expect(createdAt.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
485
+ });
486
+
487
+ test("Explicit values override defaults", async () => {
488
+ // Default for created_by is @user_id (which would be p_user_id = 1)
489
+ // If we explicitly provide the same value, it should work
490
+ // Better test: Use a different timestamp than @now would generate
491
+ const specificTime = '2020-01-01T00:00:00Z';
492
+
493
+ const product = await sql`
494
+ SELECT dzql_v2.save_products(1, ${sql.json({
495
+ org_id: 1,
496
+ name: "Override Defaults Product",
497
+ price: 69.99,
498
+ created_at: specificTime
499
+ })}) as product
500
+ `;
501
+
502
+ // Explicit timestamp overrides the @now default
503
+ const createdAt = new Date(product[0].product.created_at);
504
+ expect(createdAt.getFullYear()).toBe(2020);
505
+ });
506
+
507
+ test("Defaults NOT applied on UPDATE", async () => {
508
+ // Create with defaults
509
+ const created = await sql`
510
+ SELECT dzql_v2.save_products(1, ${sql.json({
511
+ org_id: 1,
512
+ name: "Update Test Product",
513
+ price: 79.99
514
+ })}) as product
515
+ `;
516
+ const productId = created[0].product.id;
517
+ const originalCreatedAt = created[0].product.created_at;
518
+
519
+ // Wait a bit
520
+ await new Promise(r => setTimeout(r, 100));
521
+
522
+ // Update (created_at should NOT change) - org_id needed for permission check
523
+ const updated = await sql`
524
+ SELECT dzql_v2.save_products(1, ${sql.json({
525
+ id: productId,
526
+ org_id: 1,
527
+ name: "Updated Product Name"
528
+ })}) as product
529
+ `;
530
+
531
+ expect(updated[0].product.created_at).toBe(originalCreatedAt);
532
+ });
533
+ });
534
+
535
+ // ============================================================
536
+ // COMPOSITE PRIMARY KEY TESTS
537
+ // ============================================================
538
+
539
+ describe("Composite Primary Keys", () => {
540
+ beforeAll(async () => {
541
+ // acts_for table already exists with composite PK (user_id, org_id, valid_from)
542
+ // Generate and apply acts_for functions
543
+ const actsForIR = ir.entities.acts_for;
544
+ const actsForSaveSQL = generateSaveFunction("acts_for", actsForIR);
545
+ const actsForGetSQL = generateGetFunction("acts_for", actsForIR);
546
+ const actsForSearchSQL = generateSearchFunction("acts_for", actsForIR);
547
+
548
+ await sql.unsafe(actsForSaveSQL);
549
+ await sql.unsafe(actsForGetSQL);
550
+ await sql.unsafe(actsForSearchSQL);
551
+
552
+ const { generateDeleteFunction } = await import("../../src/cli/codegen/sql.js");
553
+ const actsForDeleteSQL = generateDeleteFunction("acts_for", actsForIR);
554
+ await sql.unsafe(actsForDeleteSQL);
555
+ });
556
+
557
+ test("SAVE with composite PK inserts new record", async () => {
558
+ const result = await sql`
559
+ SELECT dzql_v2.save_acts_for(1, ${sql.json({
560
+ user_id: 1,
561
+ org_id: 3,
562
+ valid_from: '2025-01-01',
563
+ active: true
564
+ })}) as record
565
+ `;
566
+
567
+ expect(result[0].record).toBeDefined();
568
+ expect(result[0].record.user_id).toBe(1);
569
+ expect(result[0].record.org_id).toBe(3);
570
+ expect(result[0].record.active).toBe(true);
571
+ });
572
+
573
+ test("SAVE with composite PK updates existing record", async () => {
574
+ // First insert
575
+ await sql`
576
+ SELECT dzql_v2.save_acts_for(1, ${sql.json({
577
+ user_id: 1,
578
+ org_id: 3,
579
+ valid_from: '2025-02-01',
580
+ active: true
581
+ })})
582
+ `;
583
+
584
+ // Update using same composite PK
585
+ const result = await sql`
586
+ SELECT dzql_v2.save_acts_for(1, ${sql.json({
587
+ user_id: 1,
588
+ org_id: 3,
589
+ valid_from: '2025-02-01',
590
+ active: false
591
+ })}) as record
592
+ `;
593
+
594
+ expect(result[0].record.active).toBe(false);
595
+
596
+ // Verify only one record exists
597
+ const count = await sql`
598
+ SELECT COUNT(*) as cnt FROM acts_for
599
+ WHERE user_id = 1 AND org_id = 3 AND valid_from = '2025-02-01'
600
+ `;
601
+ expect(parseInt(count[0].cnt)).toBe(1);
602
+ });
603
+
604
+ test("GET with composite PK retrieves correct record", async () => {
605
+ // Insert a specific record
606
+ await sql`
607
+ SELECT dzql_v2.save_acts_for(1, ${sql.json({
608
+ user_id: 1,
609
+ org_id: 3,
610
+ valid_from: '2025-03-01',
611
+ active: true
612
+ })})
613
+ `;
614
+
615
+ const result = await sql`
616
+ SELECT dzql_v2.get_acts_for(1, ${sql.json({
617
+ user_id: 1,
618
+ org_id: 3,
619
+ valid_from: '2025-03-01'
620
+ })}) as record
621
+ `;
622
+
623
+ expect(result[0].record).toBeDefined();
624
+ expect(result[0].record.user_id).toBe(1);
625
+ expect(result[0].record.org_id).toBe(3);
626
+ });
627
+
628
+ test("DELETE with composite PK removes correct record", async () => {
629
+ // Insert
630
+ await sql`
631
+ SELECT dzql_v2.save_acts_for(1, ${sql.json({
632
+ user_id: 1,
633
+ org_id: 3,
634
+ valid_from: '2025-04-01',
635
+ active: true
636
+ })})
637
+ `;
638
+
639
+ // Delete
640
+ await sql`
641
+ SELECT dzql_v2.delete_acts_for(1, ${sql.json({
642
+ user_id: 1,
643
+ org_id: 3,
644
+ valid_from: '2025-04-01'
645
+ })})
646
+ `;
647
+
648
+ // Verify deleted
649
+ const count = await sql`
650
+ SELECT COUNT(*) as cnt FROM acts_for
651
+ WHERE user_id = 1 AND org_id = 3 AND valid_from = '2025-04-01'
652
+ `;
653
+ expect(parseInt(count[0].cnt)).toBe(0);
654
+ });
655
+ });
656
+
657
+ // ============================================================
658
+ // CUSTOM FUNCTIONS TESTS
659
+ // ============================================================
660
+
661
+ describe("Custom Functions", () => {
662
+ test("Custom function is included in IR", () => {
663
+ // Domain with custom function
664
+ const domainWithCustomFn = {
665
+ entities: {},
666
+ subscribables: {},
667
+ customFunctions: [
668
+ {
669
+ name: 'calculate_org_stats',
670
+ sql: `
671
+ CREATE OR REPLACE FUNCTION dzql_v2.calculate_org_stats(p_user_id int, p_params jsonb)
672
+ RETURNS jsonb LANGUAGE plpgsql AS $$
673
+ DECLARE
674
+ v_org_id int;
675
+ v_venue_count int;
676
+ v_site_count int;
677
+ BEGIN
678
+ v_org_id := (p_params->>'org_id')::int;
679
+
680
+ SELECT COUNT(*) INTO v_venue_count
681
+ FROM venues WHERE org_id = v_org_id;
682
+
683
+ SELECT COUNT(*) INTO v_site_count
684
+ FROM sites s
685
+ JOIN venues v ON s.venue_id = v.id
686
+ WHERE v.org_id = v_org_id;
687
+
688
+ RETURN jsonb_build_object(
689
+ 'org_id', v_org_id,
690
+ 'venue_count', v_venue_count,
691
+ 'site_count', v_site_count
692
+ );
693
+ END;
694
+ $$;
695
+ `,
696
+ args: ['p_user_id', 'p_params']
697
+ }
698
+ ]
699
+ };
700
+
701
+ const customIR = generateIR(domainWithCustomFn);
702
+
703
+ expect(customIR.customFunctions).toBeDefined();
704
+ expect(customIR.customFunctions.length).toBe(1);
705
+ expect(customIR.customFunctions[0].name).toBe('calculate_org_stats');
706
+ expect(customIR.customFunctions[0].args).toEqual(['p_user_id', 'p_params']);
707
+ });
708
+
709
+ test("Custom function is included in manifest allowlist", async () => {
710
+ const { generateManifest } = await import("../../src/cli/codegen/manifest.js");
711
+
712
+ const domainWithCustomFn = {
713
+ entities: {},
714
+ subscribables: {},
715
+ customFunctions: [
716
+ {
717
+ name: 'my_custom_func',
718
+ sql: 'SELECT 1',
719
+ args: ['p_user_id', 'p_params']
720
+ }
721
+ ]
722
+ };
723
+
724
+ const customIR = generateIR(domainWithCustomFn);
725
+ const manifest = generateManifest(customIR);
726
+
727
+ expect(manifest.functions['my_custom_func']).toBeDefined();
728
+ expect(manifest.functions['my_custom_func'].schema).toBe('dzql_v2');
729
+ expect(manifest.functions['my_custom_func'].name).toBe('my_custom_func');
730
+ expect(manifest.functions['my_custom_func'].args).toEqual(['p_user_id', 'p_params']);
731
+ });
732
+
733
+ test("Custom function can be applied and called", async () => {
734
+ // Create a custom function that calculates org statistics
735
+ const customFunctionSQL = `
736
+ CREATE OR REPLACE FUNCTION dzql_v2.calculate_org_stats(p_user_id int, p_params jsonb)
737
+ RETURNS jsonb LANGUAGE plpgsql AS $$
738
+ DECLARE
739
+ v_org_id int;
740
+ v_venue_count int;
741
+ v_site_count int;
742
+ BEGIN
743
+ v_org_id := (p_params->>'org_id')::int;
744
+
745
+ SELECT COUNT(*) INTO v_venue_count
746
+ FROM venues WHERE org_id = v_org_id;
747
+
748
+ SELECT COUNT(*) INTO v_site_count
749
+ FROM sites s
750
+ JOIN venues v ON s.venue_id = v.id
751
+ WHERE v.org_id = v_org_id;
752
+
753
+ RETURN jsonb_build_object(
754
+ 'org_id', v_org_id,
755
+ 'venue_count', v_venue_count,
756
+ 'site_count', v_site_count
757
+ );
758
+ END;
759
+ $$;
760
+ `;
761
+
762
+ // Apply the function
763
+ await sql.unsafe(customFunctionSQL);
764
+
765
+ // Call it - org 1 has 2 venues and 3 sites (from test setup)
766
+ const result = await sql`
767
+ SELECT dzql_v2.calculate_org_stats(1, '{"org_id": 1}'::jsonb) as stats
768
+ `;
769
+
770
+ const stats = result[0].stats;
771
+ expect(stats.org_id).toBe(1);
772
+ expect(stats.venue_count).toBe(2); // Venue A, Venue B
773
+ expect(stats.site_count).toBe(3); // Site 1, Site 2, Site 3
774
+ });
775
+
776
+ test("Custom function with default args uses standard signature", () => {
777
+ const domainWithDefaultArgs = {
778
+ entities: {},
779
+ subscribables: {},
780
+ customFunctions: [
781
+ {
782
+ name: 'simple_func',
783
+ sql: 'SELECT 1'
784
+ // No args specified - should default to ['p_user_id', 'p_params']
785
+ }
786
+ ]
787
+ };
788
+
789
+ const customIR = generateIR(domainWithDefaultArgs);
790
+ expect(customIR.customFunctions[0].args).toEqual(['p_user_id', 'p_params']);
791
+ });
792
+ });
793
+
794
+ // ============================================================
795
+ // JAVASCRIPT CUSTOM FUNCTIONS TESTS
796
+ // ============================================================
797
+
798
+ describe("JavaScript Custom Functions", () => {
799
+ beforeAll(() => {
800
+ // Clear any previously registered functions
801
+ clearJsFunctions();
802
+
803
+ // Load a minimal manifest for the runtime
804
+ loadManifest({
805
+ version: '2.0.0',
806
+ functions: {
807
+ // Add a SQL function to test that JS takes precedence
808
+ test_sql_func: {
809
+ schema: 'dzql_v2',
810
+ name: 'test_sql_func',
811
+ args: ['p_user_id', 'p_params'],
812
+ returnType: 'jsonb'
813
+ }
814
+ },
815
+ entities: {},
816
+ subscribables: {}
817
+ });
818
+ });
819
+
820
+ afterAll(() => {
821
+ clearJsFunctions();
822
+ });
823
+
824
+ test("JS function can be registered and called", async () => {
825
+ // Register a simple JS function
826
+ registerJsFunction('my_js_func', async (ctx) => {
827
+ return {
828
+ message: 'Hello from JS!',
829
+ userId: ctx.userId,
830
+ params: ctx.params
831
+ };
832
+ });
833
+
834
+ // Create a mock db client
835
+ const mockDb = {
836
+ query: async () => []
837
+ };
838
+
839
+ // Call via handleRequest
840
+ const result = await handleRequest(mockDb, 'my_js_func', { foo: 'bar' }, 42);
841
+
842
+ expect(result.message).toBe('Hello from JS!');
843
+ expect(result.userId).toBe(42);
844
+ expect(result.params.foo).toBe('bar');
845
+ });
846
+
847
+ test("JS function can query the database", async () => {
848
+ // Register a JS function that queries the database
849
+ registerJsFunction('count_venues_js', async (ctx) => {
850
+ const rows = await ctx.db.query(
851
+ 'SELECT COUNT(*) as cnt FROM venues WHERE org_id = $1',
852
+ [ctx.params.org_id]
853
+ );
854
+ return {
855
+ org_id: ctx.params.org_id,
856
+ venue_count: parseInt(rows[0].cnt)
857
+ };
858
+ });
859
+
860
+ // Use the real test database
861
+ const dbClient = {
862
+ query: async (text: string, params: any[]) => {
863
+ return await sql.unsafe(text, params);
864
+ }
865
+ };
866
+
867
+ // Call via handleRequest - org 1 has 2 venues from test setup
868
+ const result = await handleRequest(dbClient, 'count_venues_js', { org_id: 1 }, 1);
869
+
870
+ expect(result.org_id).toBe(1);
871
+ expect(result.venue_count).toBe(2);
872
+ });
873
+
874
+ test("JS function takes precedence over SQL function with same name", async () => {
875
+ // Register a JS function with a name that's also in the manifest
876
+ registerJsFunction('test_sql_func', async (ctx) => {
877
+ return { source: 'javascript', userId: ctx.userId };
878
+ });
879
+
880
+ const mockDb = {
881
+ query: async () => {
882
+ // This should NOT be called - JS takes precedence
883
+ throw new Error('SQL function should not be called');
884
+ }
885
+ };
886
+
887
+ const result = await handleRequest(mockDb, 'test_sql_func', {}, 1);
888
+ expect(result.source).toBe('javascript');
889
+ });
890
+
891
+ test("JS function can throw errors", async () => {
892
+ registerJsFunction('error_func', async () => {
893
+ throw new Error('Custom error from JS');
894
+ });
895
+
896
+ const mockDb = { query: async () => [] };
897
+
898
+ await expect(handleRequest(mockDb, 'error_func', {}, 1))
899
+ .rejects.toThrow('Custom error from JS');
900
+ });
901
+
902
+ test("JS function receives correct context", async () => {
903
+ let capturedContext: any = null;
904
+
905
+ registerJsFunction('capture_context', async (ctx) => {
906
+ capturedContext = {
907
+ userId: ctx.userId,
908
+ params: ctx.params,
909
+ hasDbQuery: typeof ctx.db.query === 'function'
910
+ };
911
+ return { ok: true };
912
+ });
913
+
914
+ const mockDb = { query: async () => [] };
915
+ await handleRequest(mockDb, 'capture_context', { test: 123 }, 99);
916
+
917
+ expect(capturedContext.userId).toBe(99);
918
+ expect(capturedContext.params.test).toBe(123);
919
+ expect(capturedContext.hasDbQuery).toBe(true);
920
+ });
921
+ });
922
+ });