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.
- package/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +309 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +653 -0
- package/docs/project-setup.md +456 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +166 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for TzqlNamespace - invokej integration
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the namespace works correctly with the manifest
|
|
5
|
+
* and can execute CRUD operations against a real database.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from "bun:test";
|
|
9
|
+
import { V2TestDatabase } from "./integration/setup.js";
|
|
10
|
+
import { generateCoreSQL, generateEntitySQL, generateSchemaSQL } from "../src/cli/codegen/sql.js";
|
|
11
|
+
import { generateManifest } from "../src/cli/codegen/manifest.js";
|
|
12
|
+
import { generateIR } from "../src/cli/compiler/ir.js";
|
|
13
|
+
import { entities } from "../examples/blog.js";
|
|
14
|
+
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
|
|
17
|
+
const blogDomain = { entities, subscribables: {} };
|
|
18
|
+
|
|
19
|
+
describe("TzqlNamespace", () => {
|
|
20
|
+
let db: V2TestDatabase;
|
|
21
|
+
let sql: any;
|
|
22
|
+
let testManifestPath: string;
|
|
23
|
+
let originalEnv: string | undefined;
|
|
24
|
+
let consoleOutput: string[] = [];
|
|
25
|
+
let consoleErrorOutput: string[] = [];
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
// Setup test database
|
|
29
|
+
db = new V2TestDatabase();
|
|
30
|
+
sql = await db.setup();
|
|
31
|
+
|
|
32
|
+
// Generate and apply SQL
|
|
33
|
+
const ir = generateIR(blogDomain);
|
|
34
|
+
const coreSQL = generateCoreSQL();
|
|
35
|
+
const postsSchema = generateSchemaSQL("posts", ir.entities.posts);
|
|
36
|
+
const commentsSchema = generateSchemaSQL("comments", ir.entities.comments);
|
|
37
|
+
const postsSQL = generateEntitySQL("posts", ir.entities.posts);
|
|
38
|
+
const commentsSQL = generateEntitySQL("comments", ir.entities.comments);
|
|
39
|
+
|
|
40
|
+
await db.applySQL(coreSQL);
|
|
41
|
+
await db.applySQL(postsSchema);
|
|
42
|
+
await db.applySQL(commentsSchema);
|
|
43
|
+
await db.applySQL(postsSQL);
|
|
44
|
+
await db.applySQL(commentsSQL);
|
|
45
|
+
|
|
46
|
+
// Create users table for auth functions
|
|
47
|
+
await db.applySQL(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
49
|
+
id serial PRIMARY KEY,
|
|
50
|
+
email text UNIQUE NOT NULL,
|
|
51
|
+
password_hash text NOT NULL,
|
|
52
|
+
name text
|
|
53
|
+
)
|
|
54
|
+
`);
|
|
55
|
+
|
|
56
|
+
// Generate manifest and write to temp location
|
|
57
|
+
const manifest = generateManifest(ir);
|
|
58
|
+
testManifestPath = join(process.cwd(), "dist/runtime");
|
|
59
|
+
mkdirSync(testManifestPath, { recursive: true });
|
|
60
|
+
writeFileSync(
|
|
61
|
+
join(testManifestPath, "manifest.json"),
|
|
62
|
+
JSON.stringify(manifest, null, 2)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Set DATABASE_URL to test database
|
|
66
|
+
originalEnv = process.env.DATABASE_URL;
|
|
67
|
+
const testDbUrl = db.baseUrl.replace(/\/[^/]*$/, `/${db.dbName}`);
|
|
68
|
+
process.env.DATABASE_URL = testDbUrl;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
// Restore environment
|
|
73
|
+
if (originalEnv !== undefined) {
|
|
74
|
+
process.env.DATABASE_URL = originalEnv;
|
|
75
|
+
} else {
|
|
76
|
+
delete process.env.DATABASE_URL;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Cleanup manifest
|
|
80
|
+
try {
|
|
81
|
+
rmSync(join(testManifestPath, "manifest.json"));
|
|
82
|
+
} catch {}
|
|
83
|
+
|
|
84
|
+
await db.teardown();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
consoleOutput = [];
|
|
89
|
+
consoleErrorOutput = [];
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Helper to capture console output and prevent process.exit
|
|
93
|
+
function setupMocks() {
|
|
94
|
+
const logSpy = spyOn(console, "log").mockImplementation((...args) => {
|
|
95
|
+
consoleOutput.push(args.map(String).join(" "));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const errorSpy = spyOn(console, "error").mockImplementation((...args) => {
|
|
99
|
+
consoleErrorOutput.push(args.map(String).join(" "));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const exitSpy = spyOn(process, "exit").mockImplementation((() => {
|
|
103
|
+
// Don't actually exit - just throw to stop execution
|
|
104
|
+
throw new Error("EXIT");
|
|
105
|
+
}) as any);
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
logSpy.mockRestore();
|
|
109
|
+
errorSpy.mockRestore();
|
|
110
|
+
exitSpy.mockRestore();
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Helper to parse JSON output - find the first valid JSON object
|
|
115
|
+
function parseOutput(): any {
|
|
116
|
+
for (const line of consoleOutput) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(line);
|
|
119
|
+
if (parsed && typeof parsed === 'object' && 'success' in parsed) {
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseErrorOutput(): any {
|
|
130
|
+
if (consoleErrorOutput.length === 0) return null;
|
|
131
|
+
// Find JSON in error output
|
|
132
|
+
for (const line of consoleErrorOutput) {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(line);
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("entities() lists available entities", async () => {
|
|
143
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
144
|
+
const ns = new TzqlNamespace();
|
|
145
|
+
|
|
146
|
+
const restore = setupMocks();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await ns.entities();
|
|
150
|
+
} catch (e: any) {
|
|
151
|
+
if (e.message !== "EXIT") throw e;
|
|
152
|
+
} finally {
|
|
153
|
+
restore();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = parseOutput();
|
|
157
|
+
expect(result).not.toBeNull();
|
|
158
|
+
expect(result.success).toBe(true);
|
|
159
|
+
expect(result.entities).toBeDefined();
|
|
160
|
+
expect(result.entities.posts).toBeDefined();
|
|
161
|
+
expect(result.entities.comments).toBeDefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("functions() lists available functions", async () => {
|
|
165
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
166
|
+
const ns = new TzqlNamespace();
|
|
167
|
+
|
|
168
|
+
const restore = setupMocks();
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await ns.functions();
|
|
172
|
+
} catch (e: any) {
|
|
173
|
+
if (e.message !== "EXIT") throw e;
|
|
174
|
+
} finally {
|
|
175
|
+
restore();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = parseOutput();
|
|
179
|
+
expect(result).not.toBeNull();
|
|
180
|
+
expect(result.success).toBe(true);
|
|
181
|
+
expect(result.functions).toBeDefined();
|
|
182
|
+
expect(result.functions.save_posts).toBeDefined();
|
|
183
|
+
expect(result.functions.get_posts).toBeDefined();
|
|
184
|
+
expect(result.functions.search_posts).toBeDefined();
|
|
185
|
+
expect(result.functions.delete_posts).toBeDefined();
|
|
186
|
+
expect(result.functions.login_user).toBeDefined();
|
|
187
|
+
expect(result.functions.register_user).toBeDefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("save() creates a new entity", async () => {
|
|
191
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
192
|
+
const ns = new TzqlNamespace(1); // userId = 1
|
|
193
|
+
|
|
194
|
+
const restore = setupMocks();
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await ns.save(
|
|
198
|
+
null,
|
|
199
|
+
"posts",
|
|
200
|
+
JSON.stringify({ title: "Test Post", content: "Hello", author_id: 1 })
|
|
201
|
+
);
|
|
202
|
+
} catch (e: any) {
|
|
203
|
+
if (e.message !== "EXIT") throw e;
|
|
204
|
+
} finally {
|
|
205
|
+
restore();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = parseOutput();
|
|
209
|
+
expect(result).not.toBeNull();
|
|
210
|
+
expect(result.success).toBe(true);
|
|
211
|
+
expect(result.result).toBeDefined();
|
|
212
|
+
expect(result.result.id).toBeDefined();
|
|
213
|
+
expect(result.result.title).toBe("Test Post");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("get() retrieves an entity by ID", async () => {
|
|
217
|
+
// First create a post directly in DB
|
|
218
|
+
const created = await sql`
|
|
219
|
+
INSERT INTO posts (title, content, author_id)
|
|
220
|
+
VALUES ('Get Test', 'Content', 1)
|
|
221
|
+
RETURNING id
|
|
222
|
+
`;
|
|
223
|
+
const postId = created[0].id;
|
|
224
|
+
|
|
225
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
226
|
+
const ns = new TzqlNamespace(1);
|
|
227
|
+
|
|
228
|
+
const restore = setupMocks();
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await ns.get(null, "posts", JSON.stringify({ id: postId }));
|
|
232
|
+
} catch (e: any) {
|
|
233
|
+
if (e.message !== "EXIT") throw e;
|
|
234
|
+
} finally {
|
|
235
|
+
restore();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const result = parseOutput();
|
|
239
|
+
expect(result).not.toBeNull();
|
|
240
|
+
expect(result.success).toBe(true);
|
|
241
|
+
expect(result.result).toBeDefined();
|
|
242
|
+
expect(result.result.id).toBe(postId);
|
|
243
|
+
expect(result.result.title).toBe("Get Test");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("search() finds entities", async () => {
|
|
247
|
+
// Create some posts
|
|
248
|
+
await sql`
|
|
249
|
+
INSERT INTO posts (title, content, author_id)
|
|
250
|
+
VALUES ('Search Post 1', 'Content', 1), ('Search Post 2', 'More', 1)
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
254
|
+
const ns = new TzqlNamespace(1);
|
|
255
|
+
|
|
256
|
+
const restore = setupMocks();
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await ns.search(null, "posts", JSON.stringify({ limit: 10 }));
|
|
260
|
+
} catch (e: any) {
|
|
261
|
+
if (e.message !== "EXIT") throw e;
|
|
262
|
+
} finally {
|
|
263
|
+
restore();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const result = parseOutput();
|
|
267
|
+
expect(result).not.toBeNull();
|
|
268
|
+
expect(result.success).toBe(true);
|
|
269
|
+
expect(result.result).toBeArray();
|
|
270
|
+
expect(result.result.length).toBeGreaterThan(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("delete() removes an entity", async () => {
|
|
274
|
+
// Create a post to delete - author_id must match userId for permission
|
|
275
|
+
const created = await sql`
|
|
276
|
+
INSERT INTO posts (title, content, author_id)
|
|
277
|
+
VALUES ('To Delete', 'Content', 1)
|
|
278
|
+
RETURNING id
|
|
279
|
+
`;
|
|
280
|
+
const postId = created[0].id;
|
|
281
|
+
|
|
282
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
283
|
+
const ns = new TzqlNamespace(1); // userId must match author_id
|
|
284
|
+
|
|
285
|
+
const restore = setupMocks();
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await ns.delete(null, "posts", JSON.stringify({ id: postId }));
|
|
289
|
+
} catch (e: any) {
|
|
290
|
+
if (e.message !== "EXIT") throw e;
|
|
291
|
+
} finally {
|
|
292
|
+
restore();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const result = parseOutput();
|
|
296
|
+
const errorResult = parseErrorOutput();
|
|
297
|
+
|
|
298
|
+
if (!result && errorResult) {
|
|
299
|
+
console.log("Delete failed with:", errorResult);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
expect(result).not.toBeNull();
|
|
303
|
+
expect(result.success).toBe(true);
|
|
304
|
+
|
|
305
|
+
// Verify it's gone
|
|
306
|
+
const check = await sql`SELECT * FROM posts WHERE id = ${postId}`;
|
|
307
|
+
expect(check.length).toBe(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("call() executes arbitrary manifest function", async () => {
|
|
311
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
312
|
+
const ns = new TzqlNamespace(1);
|
|
313
|
+
|
|
314
|
+
const restore = setupMocks();
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
await ns.call(
|
|
318
|
+
null,
|
|
319
|
+
"save_posts",
|
|
320
|
+
JSON.stringify({ title: "Call Test", content: "Via call()", author_id: 1 })
|
|
321
|
+
);
|
|
322
|
+
} catch (e: any) {
|
|
323
|
+
if (e.message !== "EXIT") throw e;
|
|
324
|
+
} finally {
|
|
325
|
+
restore();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result = parseOutput();
|
|
329
|
+
const errorResult = parseErrorOutput();
|
|
330
|
+
|
|
331
|
+
if (!result && errorResult) {
|
|
332
|
+
console.log("Call failed with:", errorResult);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
expect(result).not.toBeNull();
|
|
336
|
+
expect(result.success).toBe(true);
|
|
337
|
+
expect(result.result.title).toBe("Call Test");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("call() with unknown function returns error", async () => {
|
|
341
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
342
|
+
const ns = new TzqlNamespace(1);
|
|
343
|
+
|
|
344
|
+
const restore = setupMocks();
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await ns.call(null, "nonexistent_function", "{}");
|
|
348
|
+
} catch (e: any) {
|
|
349
|
+
if (e.message !== "EXIT") {
|
|
350
|
+
throw e;
|
|
351
|
+
}
|
|
352
|
+
} finally {
|
|
353
|
+
restore();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const errorResult = parseErrorOutput();
|
|
357
|
+
expect(errorResult).not.toBeNull();
|
|
358
|
+
expect(errorResult.success).toBe(false);
|
|
359
|
+
expect(errorResult.error).toContain("not found in manifest");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("search() without entity shows usage", async () => {
|
|
363
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
364
|
+
const ns = new TzqlNamespace(1);
|
|
365
|
+
|
|
366
|
+
const restore = setupMocks();
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
await ns.search(null, undefined, "{}");
|
|
370
|
+
} catch (e: any) {
|
|
371
|
+
if (e.message !== "EXIT") throw e;
|
|
372
|
+
} finally {
|
|
373
|
+
restore();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
expect(consoleErrorOutput.some((msg) => msg.includes("entity name required"))).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("save() with invalid JSON shows error", async () => {
|
|
380
|
+
const { TzqlNamespace } = await import("../src/runtime/namespace.js");
|
|
381
|
+
const ns = new TzqlNamespace(1);
|
|
382
|
+
|
|
383
|
+
const restore = setupMocks();
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
await ns.save(null, "posts", "not valid json");
|
|
387
|
+
} catch (e: any) {
|
|
388
|
+
if (e.message !== "EXIT") throw e;
|
|
389
|
+
} finally {
|
|
390
|
+
restore();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
expect(consoleErrorOutput.some((msg) => msg.includes("valid JSON"))).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { compilePermission } from "../src/cli/compiler/permissions.js";
|
|
3
|
+
|
|
4
|
+
// Mock IR context for resolving relationships
|
|
5
|
+
const mockContext = {
|
|
6
|
+
entities: {
|
|
7
|
+
posts: {
|
|
8
|
+
table: 'posts',
|
|
9
|
+
primaryKey: ['id'],
|
|
10
|
+
columns: [{name: 'id', type: 'int'}, {name: 'org_id', type: 'int'}]
|
|
11
|
+
},
|
|
12
|
+
organisations: {
|
|
13
|
+
table: 'organisations',
|
|
14
|
+
primaryKey: ['id'],
|
|
15
|
+
columns: [{name: 'id', type: 'int'}]
|
|
16
|
+
},
|
|
17
|
+
acts_for: {
|
|
18
|
+
table: 'acts_for',
|
|
19
|
+
primaryKey: ['user_id', 'org_id'],
|
|
20
|
+
columns: [{name: 'user_id', type: 'int'}, {name: 'org_id', type: 'int'}, {name: 'role', type: 'text'}]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("Permission Compiler", () => {
|
|
26
|
+
|
|
27
|
+
test("should compile simple owner check", () => {
|
|
28
|
+
// Rule: @author_id == @user_id
|
|
29
|
+
// Context: posts table
|
|
30
|
+
const sql = compilePermission('posts', '@author_id', mockContext);
|
|
31
|
+
|
|
32
|
+
// Should generate: (p_data->>'author_id')::int = p_user_id
|
|
33
|
+
expect(sql).toContain("(p_data->>'author_id')::int = p_user_id");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("should compile graph traversal", () => {
|
|
37
|
+
// Rule: @org_id->acts_for[org_id=$].user_id
|
|
38
|
+
// Meaning: Join via org_id from p_data, check if user_id matches
|
|
39
|
+
|
|
40
|
+
const sql = compilePermission('posts', '@org_id->acts_for[org_id=$].user_id', mockContext);
|
|
41
|
+
|
|
42
|
+
expect(sql).toContain("EXISTS");
|
|
43
|
+
expect(sql).toContain("FROM acts_for");
|
|
44
|
+
expect(sql).toContain("acts_for.org_id = (p_data->>'org_id')::int"); // Join via jsonb param
|
|
45
|
+
expect(sql).toContain("acts_for.user_id = p_user_id"); // Match
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("should compile condition", () => {
|
|
49
|
+
// Rule: @org_id->acts_for[org_id=$]{role='admin'}.user_id
|
|
50
|
+
const sql = compilePermission('posts', "@org_id->acts_for[org_id=$]{role='admin'}.user_id", mockContext);
|
|
51
|
+
|
|
52
|
+
expect(sql).toContain("acts_for.role='admin'");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generatePiniaStore } from "../src/cli/codegen/pinia.js";
|
|
3
|
+
import { generateManifest } from "../src/cli/codegen/manifest.js";
|
|
4
|
+
import { generateIR } from "../src/cli/compiler/ir.js";
|
|
5
|
+
|
|
6
|
+
// Use domain config format (with schema), not IR format
|
|
7
|
+
const mockDomainConfig = {
|
|
8
|
+
entities: {
|
|
9
|
+
posts: {
|
|
10
|
+
schema: {
|
|
11
|
+
id: "serial primary key",
|
|
12
|
+
title: "text"
|
|
13
|
+
},
|
|
14
|
+
permissions: { create: [], view: [], update: [], delete: [] }
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
subscribables: {}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mockManifest = generateManifest(generateIR(mockDomainConfig));
|
|
21
|
+
|
|
22
|
+
describe("Pinia Store Generation", () => {
|
|
23
|
+
test("should generate a Pinia store with basic CRUD and table_changed", () => {
|
|
24
|
+
const piniaCode = generatePiniaStore(mockManifest, "posts");
|
|
25
|
+
|
|
26
|
+
// Check TypeScript imports
|
|
27
|
+
expect(piniaCode).toContain("import { defineStore } from 'pinia'");
|
|
28
|
+
expect(piniaCode).toContain("import { ref, type Ref } from 'vue'");
|
|
29
|
+
expect(piniaCode).toContain("import { ws } from '../../client.js';");
|
|
30
|
+
|
|
31
|
+
// Check type definitions
|
|
32
|
+
expect(piniaCode).toContain("export interface Posts {");
|
|
33
|
+
expect(piniaCode).toContain("export interface TableChangedPayload {");
|
|
34
|
+
|
|
35
|
+
// Check store definition
|
|
36
|
+
expect(piniaCode).toContain("export const usePostsStore = defineStore('posts-store', () => {");
|
|
37
|
+
|
|
38
|
+
// Check typed CRUD methods
|
|
39
|
+
expect(piniaCode).toContain("async function get(id: number): Promise<Posts | null>");
|
|
40
|
+
expect(piniaCode).toContain("async function save(data: Partial<Posts>): Promise<Posts>");
|
|
41
|
+
expect(piniaCode).toContain("async function remove(id: number): Promise<Posts>");
|
|
42
|
+
expect(piniaCode).toContain("async function search(query:");
|
|
43
|
+
|
|
44
|
+
// Check table_changed handler
|
|
45
|
+
expect(piniaCode).toContain("function table_changed(payload: TableChangedPayload): void");
|
|
46
|
+
expect(piniaCode).toContain("if (payload.table === 'posts')");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generateAffectedKeysFunction } from "../src/cli/codegen/realtime.js";
|
|
3
|
+
|
|
4
|
+
const mockSubscribable = {
|
|
5
|
+
name: "post_detail",
|
|
6
|
+
params: { post_id: "int" },
|
|
7
|
+
root: { entity: "posts", key: "post_id" },
|
|
8
|
+
scopeTables: ["posts", "comments"]
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("Realtime Logic Generation", () => {
|
|
12
|
+
test("should generate affected keys function", () => {
|
|
13
|
+
const sql = generateAffectedKeysFunction("post_detail", mockSubscribable);
|
|
14
|
+
|
|
15
|
+
expect(sql).toContain("CREATE OR REPLACE FUNCTION dzql_v2.post_detail_affected_keys");
|
|
16
|
+
expect(sql).toContain("RETURNS text[]");
|
|
17
|
+
|
|
18
|
+
// Logic check: should return array of keys
|
|
19
|
+
expect(sql).toContain("WHEN 'posts' THEN");
|
|
20
|
+
expect(sql).toContain("RETURN ARRAY['post_detail:' || COALESCE(p_new->>'id', p_old->>'id')]");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll } from "bun:test";
|
|
2
|
+
import { loadManifest } from "../src/runtime/manifest_loader.js";
|
|
3
|
+
import { handleRequest } from "../src/runtime/server.js";
|
|
4
|
+
import { ErrorCode } from "../src/runtime/errors.js";
|
|
5
|
+
|
|
6
|
+
// Mock Manifest
|
|
7
|
+
const mockManifest = {
|
|
8
|
+
version: "2.0.0",
|
|
9
|
+
functions: {
|
|
10
|
+
"save_posts": {
|
|
11
|
+
schema: "dzql_v2",
|
|
12
|
+
name: "save_posts",
|
|
13
|
+
args: ["p_user_id", "p_data"],
|
|
14
|
+
returnType: "jsonb"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
entities: {},
|
|
18
|
+
subscribables: {}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Mock DB
|
|
22
|
+
const mockDB = {
|
|
23
|
+
query: async (text: string, params: any[]) => {
|
|
24
|
+
// Simulate Success
|
|
25
|
+
if (params[1].title === "Hello") {
|
|
26
|
+
return [{ result: { id: 1, ...params[1] } }];
|
|
27
|
+
}
|
|
28
|
+
// Simulate Permission Error
|
|
29
|
+
if (params[1].title === "Hacked") {
|
|
30
|
+
const e: any = new Error("permission_denied");
|
|
31
|
+
e.code = "P0001";
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
// Simulate Unique Violation
|
|
35
|
+
if (params[1].title === "Duplicate") {
|
|
36
|
+
const e: any = new Error("duplicate key value");
|
|
37
|
+
e.code = "23505";
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("Runtime Security", () => {
|
|
45
|
+
beforeAll(() => {
|
|
46
|
+
loadManifest(mockManifest);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("should execute allowlisted function", async () => {
|
|
50
|
+
const result = await handleRequest(mockDB, "save_posts", { title: "Hello" }, 1);
|
|
51
|
+
expect(result.id).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("should reject unknown function (Injection Attempt)", async () => {
|
|
55
|
+
try {
|
|
56
|
+
await handleRequest(mockDB, "pg_sleep", {}, 1);
|
|
57
|
+
expect(true).toBe(false);
|
|
58
|
+
} catch (e: any) {
|
|
59
|
+
expect(e.message).toContain("not found in manifest");
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("should return PERMISSION_DENIED", async () => {
|
|
64
|
+
try {
|
|
65
|
+
await handleRequest(mockDB, "save_posts", { title: "Hacked" }, 1);
|
|
66
|
+
expect(true).toBe(false);
|
|
67
|
+
} catch (e: any) {
|
|
68
|
+
expect(e.code).toBe(ErrorCode.PERMISSION_DENIED);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("should return CONFLICT", async () => {
|
|
73
|
+
try {
|
|
74
|
+
await handleRequest(mockDB, "save_posts", { title: "Duplicate" }, 1);
|
|
75
|
+
expect(true).toBe(false);
|
|
76
|
+
} catch (e: any) {
|
|
77
|
+
expect(e.code).toBe(ErrorCode.CONFLICT);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generateSubscribableStore } from "../src/cli/codegen/subscribable_store.js";
|
|
3
|
+
import { generateSubscribableSQL } from "../src/cli/codegen/subscribable_sql.js";
|
|
4
|
+
import { generateManifest } from "../src/cli/codegen/manifest.js";
|
|
5
|
+
import { generateIR } from "../src/cli/compiler/ir.js";
|
|
6
|
+
import { entities, subscribables } from "../examples/venues.js";
|
|
7
|
+
|
|
8
|
+
const venuesDomain = { entities, subscribables };
|
|
9
|
+
|
|
10
|
+
describe("Subscribable Store Generation", () => {
|
|
11
|
+
test("should generate venue_detail store with deep graph patching", () => {
|
|
12
|
+
const ir = generateIR(venuesDomain);
|
|
13
|
+
const manifest = generateManifest(ir);
|
|
14
|
+
|
|
15
|
+
// Generate the store for the 'venue_detail' subscribable
|
|
16
|
+
const code = generateSubscribableStore(manifest, "venue_detail");
|
|
17
|
+
|
|
18
|
+
console.log("--- GENERATED VENUE DETAIL STORE ---");
|
|
19
|
+
console.log(code);
|
|
20
|
+
console.log("------------------------------------");
|
|
21
|
+
|
|
22
|
+
expect(code).toContain("useVenueDetailStore");
|
|
23
|
+
// Check for TypeScript interface
|
|
24
|
+
expect(code).toContain("export interface VenueDetailParams");
|
|
25
|
+
// Check for nested table handling
|
|
26
|
+
expect(code).toContain("case 'sites':");
|
|
27
|
+
expect(code).toContain("case 'allocations':");
|
|
28
|
+
// Check for parent lookup logic with type annotation
|
|
29
|
+
expect(code).toContain("const parent = doc.sites?.find");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("should generate async bind function that awaits first data", () => {
|
|
33
|
+
const ir = generateIR(venuesDomain);
|
|
34
|
+
const manifest = generateManifest(ir);
|
|
35
|
+
|
|
36
|
+
const code = generateSubscribableStore(manifest, "venue_detail");
|
|
37
|
+
|
|
38
|
+
// Check that bind is async with typed params
|
|
39
|
+
expect(code).toContain("async function bind(params: VenueDetailParams)");
|
|
40
|
+
// Check for Promise-based ready signal
|
|
41
|
+
expect(code).toContain("const ready = new Promise<void>");
|
|
42
|
+
expect(code).toContain("resolveReady()");
|
|
43
|
+
// Check that bind awaits ready
|
|
44
|
+
expect(code).toContain("await ready");
|
|
45
|
+
// Check that plain object with empty data is stored (preserves reactivity on merge)
|
|
46
|
+
expect(code).toContain("{ data: {}, loading: true, ready }");
|
|
47
|
+
// Check initial data is merged via Object.assign to preserve reactivity
|
|
48
|
+
expect(code).toContain("Object.assign(documents.value[key].data, eventData");
|
|
49
|
+
// Check existing subscription handling waits for ready
|
|
50
|
+
expect(code).toContain("await existing.ready");
|
|
51
|
+
// Check unbind function exists with typed params
|
|
52
|
+
expect(code).toContain("function unbind(params: VenueDetailParams)");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("should generate flat SQL structure for nested includes", () => {
|
|
56
|
+
const ir = generateIR(venuesDomain);
|
|
57
|
+
const sub = ir.subscribables.venue_detail;
|
|
58
|
+
|
|
59
|
+
const sql = generateSubscribableSQL("venue_detail", sub, ir.entities);
|
|
60
|
+
|
|
61
|
+
console.log("--- GENERATED SQL ---");
|
|
62
|
+
console.log(sql);
|
|
63
|
+
console.log("---------------------");
|
|
64
|
+
|
|
65
|
+
// Check that nested includes use jsonb concatenation (||) for flat structure
|
|
66
|
+
// This merges entity fields with nested arrays into one flat object
|
|
67
|
+
// Uses to_jsonb() (not row_to_json) for type compatibility with || operator
|
|
68
|
+
expect(sql).toContain("to_jsonb(rel.*) || jsonb_build_object(");
|
|
69
|
+
// The allocations are nested inside the flat sites object
|
|
70
|
+
expect(sql).toContain("'allocations', COALESCE((");
|
|
71
|
+
});
|
|
72
|
+
});
|