dzql 0.5.5 → 0.5.7

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.
@@ -0,0 +1,554 @@
1
+ # Drop Semantics
2
+
3
+ Compile-time manifest describing valid drag-and-drop interactions for canvas UIs.
4
+
5
+ ## Overview
6
+
7
+ When you compile entity definitions, DZQL generates a `drop-semantics.json` file that describes all valid drag-and-drop relationships between entities. This allows canvas UIs to:
8
+
9
+ - Know which entities can be dropped onto which targets
10
+ - Display appropriate visual feedback (containment, frames, edges, badges)
11
+ - Execute the correct database operation for each drop
12
+ - Provide unlink/remove functionality
13
+
14
+ **Key benefit:** The canvas never interprets SQL - it reads a static manifest and knows exactly what connections are valid.
15
+
16
+ ## Quick Start
17
+
18
+ ```bash
19
+ dzql compile entities/domain.sql -o compiled/
20
+ # Outputs:
21
+ # compiled/entities.sql
22
+ # compiled/drop-semantics.json ← Canvas consumes this
23
+ # compiled/checksums.json
24
+ ```
25
+
26
+ ## Output Format
27
+
28
+ ```json
29
+ {
30
+ "entities": {
31
+ "tasks": {
32
+ "droppable_on": {
33
+ "task_groups": [{
34
+ "relation": "group_id",
35
+ "type": "fk",
36
+ "action": "move",
37
+ "visual": "containment",
38
+ "label": "Move to group",
39
+ "operation": {
40
+ "method": "save",
41
+ "entity": "tasks",
42
+ "params": { "id": "@source.id", "group_id": "@target.id" }
43
+ },
44
+ "removable": true,
45
+ "remove_operation": {
46
+ "method": "save",
47
+ "entity": "tasks",
48
+ "params": { "id": "@source.id", "group_id": null }
49
+ }
50
+ }],
51
+ "users": [{
52
+ "relation": "assigned_to_user_id",
53
+ "type": "fk",
54
+ "action": "move",
55
+ "visual": "badge",
56
+ "label": "Move to user",
57
+ "primary_direction": "accepts",
58
+ "operation": { ... }
59
+ }]
60
+ },
61
+ "accepts": {
62
+ "users": [{
63
+ "relation": "assigned_to_user_id",
64
+ "type": "fk",
65
+ "action": "assign",
66
+ "visual": "badge",
67
+ "label": "Assign user",
68
+ "operation": {
69
+ "method": "save",
70
+ "entity": "tasks",
71
+ "params": { "id": "@target.id", "assigned_to_user_id": "@source.id" }
72
+ },
73
+ "removable": true,
74
+ "remove_operation": {
75
+ "method": "save",
76
+ "entity": "tasks",
77
+ "params": { "id": "@target.id", "assigned_to_user_id": null }
78
+ }
79
+ }]
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ ### Primary Direction Hint
87
+
88
+ Some relationships have a natural gesture direction. For example, you typically drop a *user* onto a *task* to assign them, not the other way around. When `primary_direction: "accepts"` is present, the canvas should prioritize the `accepts` entry for UI affordances (drop zones, visual hints).
89
+
90
+ ```json
91
+ {
92
+ "relation": "assigned_to_user_id",
93
+ "primary_direction": "accepts"
94
+ }
95
+ ```
96
+
97
+ The compiler infers this from naming patterns like `assigned_to_*`, `created_by_*`, `author`, `owner`, etc.
98
+
99
+ ## Terminology
100
+
101
+ - **source** - The entity being dragged
102
+ - **target** - The entity being dropped onto
103
+ - **droppable_on** - What THIS entity can be dropped onto
104
+ - **accepts** - What can be dropped onto THIS entity
105
+
106
+ ## Derivation Rules
107
+
108
+ The compiler derives drop semantics from your schema relationships:
109
+
110
+ ### 1. Foreign Key Relationships
111
+
112
+ ```sql
113
+ -- tasks.group_id REFERENCES task_groups
114
+ ```
115
+
116
+ Generates:
117
+
118
+ | Perspective | Entry | Meaning |
119
+ |-------------|-------|---------|
120
+ | `tasks.droppable_on.task_groups` | action: "move" | Drag task onto group → update task.group_id |
121
+ | `tasks.accepts.task_groups` | action: "assign" | Drag group onto task → update task.group_id |
122
+
123
+ ### 2. Many-to-Many (Junction Tables)
124
+
125
+ ```sql
126
+ -- post_tags(post_id, tag_id)
127
+ ```
128
+
129
+ Generates:
130
+
131
+ | Perspective | Entry | Meaning |
132
+ |-------------|-------|---------|
133
+ | `posts.droppable_on.tags` | action: "link" | Drag post onto tag → insert junction |
134
+ | `posts.accepts.tags` | action: "link" | Drag tag onto post → insert junction |
135
+
136
+ ### 3. Self-Referential FK
137
+
138
+ ```sql
139
+ -- categories.parent_id REFERENCES categories
140
+ ```
141
+
142
+ Generates:
143
+
144
+ | Perspective | Entry | Meaning |
145
+ |-------------|-------|---------|
146
+ | `categories.droppable_on.categories` | action: "reparent" | Drag category onto another → set parent |
147
+
148
+ ### 4. Self-Referential Junction (Dependencies)
149
+
150
+ ```sql
151
+ -- task_dependencies(task_id, depends_on_task_id)
152
+ ```
153
+
154
+ Generates:
155
+
156
+ | Perspective | Entry | Meaning |
157
+ |-------------|-------|---------|
158
+ | `tasks.droppable_on.tasks` | action: "link", visual: "edge" | Drag task onto task → create dependency edge |
159
+
160
+ ## Visual Types
161
+
162
+ The `visual` field tells the canvas how to render each relationship:
163
+
164
+ | Visual | Meaning | When Used |
165
+ |--------|---------|-----------|
166
+ | `containment` | Node moves inside container | Tree structures (folders, groups) |
167
+ | `frame` | Visual bounding box around members | Sets, collections |
168
+ | `edge` | Arrow drawn between nodes | Dependencies, relationships |
169
+ | `badge` | Tag/chip displayed on node | Assignments, references |
170
+
171
+ ### Automatic Visual Inference
172
+
173
+ The compiler infers visual type using these rules (in order):
174
+
175
+ 1. **Self-referential junction** → `edge`
176
+ 2. **Self-referential FK** → `containment`
177
+ 3. **Target has self-referential FK** (is a tree) → `containment`
178
+ 4. **Name ends with `_groups`, `_folders`, `_categories`** → `containment`
179
+ 5. **Name ends with `_sets`, `_collections`, `_lists`** → `frame`
180
+ 6. **Default** → `badge`
181
+
182
+ ### Edge Direction
183
+
184
+ For `edge` visuals (self-referential junctions), the output includes direction:
185
+
186
+ ```json
187
+ {
188
+ "visual": "edge",
189
+ "direction": "source_to_target",
190
+ "self_referential": true
191
+ }
192
+ ```
193
+
194
+ The canvas can use this to draw arrows in the correct direction.
195
+
196
+ ## Remove Operations
197
+
198
+ Every relationship includes remove semantics:
199
+
200
+ ### FK Relationships
201
+
202
+ ```json
203
+ {
204
+ "removable": true,
205
+ "remove_operation": {
206
+ "method": "save",
207
+ "entity": "tasks",
208
+ "params": { "id": "@source.id", "group_id": null }
209
+ }
210
+ }
211
+ ```
212
+
213
+ Setting the FK to `null` unlinks the relationship.
214
+
215
+ ### Junction Relationships
216
+
217
+ ```json
218
+ {
219
+ "removable": true,
220
+ "remove_operation": {
221
+ "method": "delete",
222
+ "entity": "post_tags",
223
+ "params": { "post_id": "@source.id", "tag_id": "@target.id" }
224
+ }
225
+ }
226
+ ```
227
+
228
+ Deleting the junction record removes the link.
229
+
230
+ ## Composite Primary Keys
231
+
232
+ Entities with composite primary keys include all key fields in params:
233
+
234
+ ```json
235
+ {
236
+ "operation": {
237
+ "method": "save",
238
+ "entity": "org_items",
239
+ "params": {
240
+ "org_id": "@source.org_id",
241
+ "item_code": "@source.item_code",
242
+ "category_id": "@target.id"
243
+ }
244
+ }
245
+ }
246
+ ```
247
+
248
+ ## Canvas Integration
249
+
250
+ ### Checking Valid Drops
251
+
252
+ ```javascript
253
+ function canDrop(sourceEntity, sourceId, targetEntity, targetId) {
254
+ const semantics = dropSemantics.entities[sourceEntity];
255
+ if (!semantics) return false;
256
+
257
+ return semantics.droppable_on[targetEntity]?.length > 0;
258
+ }
259
+ ```
260
+
261
+ ### Executing Drop
262
+
263
+ ```javascript
264
+ async function executeDrop(ws, sourceEntity, sourceData, targetEntity, targetData, relationIndex = 0) {
265
+ const action = dropSemantics.entities[sourceEntity].droppable_on[targetEntity][relationIndex];
266
+
267
+ const params = resolveParams(action.operation.params, sourceData, targetData);
268
+
269
+ if (action.operation.method === 'save') {
270
+ await ws.api.save[action.operation.entity](params);
271
+ } else if (action.operation.method === 'delete') {
272
+ await ws.api.delete[action.operation.entity](params);
273
+ }
274
+ }
275
+
276
+ function resolveParams(template, sourceData, targetData) {
277
+ const params = {};
278
+ for (const [key, value] of Object.entries(template)) {
279
+ if (typeof value === 'string' && value.startsWith('@source.')) {
280
+ params[key] = sourceData[value.replace('@source.', '')];
281
+ } else if (typeof value === 'string' && value.startsWith('@target.')) {
282
+ params[key] = targetData[value.replace('@target.', '')];
283
+ } else {
284
+ params[key] = value;
285
+ }
286
+ }
287
+ return params;
288
+ }
289
+ ```
290
+
291
+ ### Getting Visual Hint
292
+
293
+ ```javascript
294
+ function getDropVisual(sourceEntity, targetEntity) {
295
+ const action = dropSemantics.entities[sourceEntity]?.droppable_on[targetEntity]?.[0];
296
+ return action?.visual || null;
297
+ }
298
+ ```
299
+
300
+ ### Multiple Relations Picker
301
+
302
+ When multiple relations exist between the same entities (e.g., task→task could be "depends on" or "blocks"), show a picker:
303
+
304
+ ```javascript
305
+ function getDropOptions(sourceEntity, targetEntity) {
306
+ const actions = dropSemantics.entities[sourceEntity]?.droppable_on[targetEntity] || [];
307
+ return actions.map((action, index) => ({
308
+ index,
309
+ label: action.label,
310
+ visual: action.visual,
311
+ relation: action.relation
312
+ }));
313
+ }
314
+
315
+ // In Vue component
316
+ <template>
317
+ <div v-if="dropOptions.length > 1" class="relation-picker">
318
+ <button
319
+ v-for="option in dropOptions"
320
+ :key="option.index"
321
+ @click="executeDrop(option.index)"
322
+ >
323
+ {{ option.label }}
324
+ </button>
325
+ </div>
326
+ </template>
327
+ ```
328
+
329
+ ## Example: Complete Task Management
330
+
331
+ ### Schema
332
+
333
+ ```sql
334
+ -- Groups with hierarchy
335
+ CREATE TABLE task_groups (
336
+ id SERIAL PRIMARY KEY,
337
+ name TEXT NOT NULL,
338
+ parent_id INT REFERENCES task_groups(id)
339
+ );
340
+
341
+ -- Tasks
342
+ CREATE TABLE tasks (
343
+ id SERIAL PRIMARY KEY,
344
+ title TEXT NOT NULL,
345
+ group_id INT REFERENCES task_groups(id),
346
+ assigned_to_user_id INT REFERENCES users(id)
347
+ );
348
+
349
+ -- Task sets (for batch operations)
350
+ CREATE TABLE task_sets (
351
+ id SERIAL PRIMARY KEY,
352
+ name TEXT NOT NULL
353
+ );
354
+
355
+ CREATE TABLE task_set_members (
356
+ task_id INT REFERENCES tasks(id) ON DELETE CASCADE,
357
+ set_id INT REFERENCES task_sets(id) ON DELETE CASCADE,
358
+ PRIMARY KEY (task_id, set_id)
359
+ );
360
+
361
+ -- Task dependencies
362
+ CREATE TABLE task_dependencies (
363
+ task_id INT REFERENCES tasks(id) ON DELETE CASCADE,
364
+ depends_on_task_id INT REFERENCES tasks(id) ON DELETE CASCADE,
365
+ PRIMARY KEY (task_id, depends_on_task_id)
366
+ );
367
+
368
+ -- Entity registrations
369
+ SELECT dzql.register_entity('task_groups', 'name', ARRAY['name'],
370
+ jsonb_build_object('parent', 'task_groups'),
371
+ false, '{}', '{}',
372
+ jsonb_build_object('view', ARRAY[]::text[], 'create', ARRAY[]::text[],
373
+ 'update', ARRAY[]::text[], 'delete', ARRAY[]::text[])
374
+ );
375
+
376
+ SELECT dzql.register_entity('tasks', 'title', ARRAY['title'],
377
+ jsonb_build_object('group', 'task_groups', 'assigned_to_user', 'users'),
378
+ false, '{}', '{}',
379
+ jsonb_build_object('view', ARRAY[]::text[], 'create', ARRAY[]::text[],
380
+ 'update', ARRAY[]::text[], 'delete', ARRAY[]::text[]),
381
+ jsonb_build_object(
382
+ 'many_to_many', jsonb_build_object(
383
+ 'sets', jsonb_build_object(
384
+ 'junction_table', 'task_set_members',
385
+ 'local_key', 'task_id',
386
+ 'foreign_key', 'set_id',
387
+ 'target_entity', 'task_sets',
388
+ 'id_field', 'set_ids'
389
+ ),
390
+ 'dependencies', jsonb_build_object(
391
+ 'junction_table', 'task_dependencies',
392
+ 'local_key', 'task_id',
393
+ 'foreign_key', 'depends_on_task_id',
394
+ 'target_entity', 'tasks',
395
+ 'id_field', 'dependency_ids'
396
+ )
397
+ )
398
+ )
399
+ );
400
+ ```
401
+
402
+ ### Generated Drop Semantics
403
+
404
+ ```json
405
+ {
406
+ "entities": {
407
+ "task_groups": {
408
+ "droppable_on": {
409
+ "task_groups": [{
410
+ "relation": "parent_id",
411
+ "type": "fk",
412
+ "action": "reparent",
413
+ "visual": "containment",
414
+ "label": "Set parent",
415
+ "operation": { ... },
416
+ "removable": true,
417
+ "remove_operation": { ... }
418
+ }]
419
+ },
420
+ "accepts": {
421
+ "tasks": [{ ... }]
422
+ }
423
+ },
424
+ "tasks": {
425
+ "droppable_on": {
426
+ "task_groups": [{
427
+ "relation": "group_id",
428
+ "type": "fk",
429
+ "action": "move",
430
+ "visual": "containment",
431
+ "label": "Move to group",
432
+ "operation": { ... }
433
+ }],
434
+ "users": [{
435
+ "relation": "assigned_to_user_id",
436
+ "type": "fk",
437
+ "action": "move",
438
+ "visual": "badge",
439
+ "label": "Move to assigned to user",
440
+ "operation": { ... }
441
+ }],
442
+ "task_sets": [{
443
+ "relation": "task_set_members",
444
+ "type": "junction",
445
+ "action": "link",
446
+ "visual": "frame",
447
+ "label": "Add task set member",
448
+ "operation": { ... }
449
+ }],
450
+ "tasks": [{
451
+ "relation": "task_dependencies",
452
+ "type": "junction",
453
+ "action": "link",
454
+ "visual": "edge",
455
+ "direction": "source_to_target",
456
+ "label": "Add task dependency",
457
+ "operation": { ... },
458
+ "self_referential": true
459
+ }]
460
+ },
461
+ "accepts": {
462
+ "users": [{
463
+ "relation": "assigned_to_user_id",
464
+ "type": "fk",
465
+ "action": "assign",
466
+ "visual": "badge",
467
+ "label": "Assign assigned to user",
468
+ "operation": { ... }
469
+ }],
470
+ "task_sets": [{ ... }],
471
+ "tasks": [{
472
+ "visual": "edge",
473
+ "direction": "target_to_source"
474
+ }]
475
+ }
476
+ }
477
+ }
478
+ }
479
+ ```
480
+
481
+ ### Canvas Interpretation
482
+
483
+ | Drop | Visual | Result |
484
+ |------|--------|--------|
485
+ | Task → Group | containment | Task node moves inside group container |
486
+ | Task → Task | edge | Arrow drawn from source to target |
487
+ | User → Task | badge | User chip appears on task node |
488
+ | Task → Set | frame | Task included in set's visual boundary |
489
+ | Group → Group | containment | Group nests inside another group |
490
+
491
+ ## Relationship Types Summary
492
+
493
+ | Schema Pattern | Type | Action | Default Visual |
494
+ |----------------|------|--------|----------------|
495
+ | `A.fk_id REFERENCES B` | `fk` | `move` | `badge` or `containment`* |
496
+ | `A.fk_id REFERENCES A` | `fk` | `reparent` | `containment` |
497
+ | Junction(A, B) | `junction` | `link` | `badge` or `frame`* |
498
+ | Junction(A, A) | `junction` | `link` | `edge` |
499
+
500
+ *Visual depends on target entity name and structure
501
+
502
+ ## Canvas Position Storage
503
+
504
+ The drop-semantics manifest covers relationships but not node positions. For canvas x/y coordinates, consider:
505
+
506
+ ### Option A: JSON Column on Entity
507
+
508
+ Simple approach for single-user or shared layouts:
509
+
510
+ ```sql
511
+ ALTER TABLE tasks ADD COLUMN canvas JSONB DEFAULT '{}';
512
+
513
+ -- Store position
514
+ UPDATE tasks SET canvas = jsonb_build_object('x', 100, 'y', 200) WHERE id = 1;
515
+
516
+ -- Or include in entity definition for automatic handling
517
+ ```
518
+
519
+ ### Option B: Separate Positions Table
520
+
521
+ For multi-user layouts or per-project views:
522
+
523
+ ```sql
524
+ CREATE TABLE canvas_positions (
525
+ entity TEXT NOT NULL,
526
+ record_id INT NOT NULL,
527
+ user_id INT REFERENCES users(id),
528
+ project_id INT, -- Optional: per-project layouts
529
+ x FLOAT NOT NULL,
530
+ y FLOAT NOT NULL,
531
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
532
+ PRIMARY KEY (entity, record_id, COALESCE(user_id, 0), COALESCE(project_id, 0))
533
+ );
534
+
535
+ CREATE INDEX idx_canvas_positions_lookup
536
+ ON canvas_positions(entity, user_id, project_id);
537
+ ```
538
+
539
+ ### Option C: Client-Side Storage
540
+
541
+ For personal layouts that don't need server persistence:
542
+
543
+ ```javascript
544
+ // localStorage per user
545
+ const positions = JSON.parse(localStorage.getItem('canvas_positions') || '{}');
546
+ positions[`${entity}:${id}`] = { x, y };
547
+ localStorage.setItem('canvas_positions', JSON.stringify(positions));
548
+ ```
549
+
550
+ ## See Also
551
+
552
+ - [Many-to-Many](./many-to-many.md) - Junction table configuration
553
+ - [Compiler Guide](../compiler/README.md) - Full compilation workflow
554
+ - [Custom Functions](./custom-functions.md) - Extending with business logic
@@ -19,7 +19,9 @@ Live Query Subscriptions (Pattern 1 from vision.md) enable clients to subscribe
19
19
  - `get_<name>(params, user_id)` - Query function
20
20
  - `<name>_affected_documents(table, op, old, new)` - Change detection
21
21
  3. **Subscribe**: Client calls `ws.api.subscribe_<name>(params, callback)`
22
- 4. **Update**: Database changes trigger NOTIFY → server asks PostgreSQL which subscriptions are affected server re-queries and sends updates
22
+ 4. **Update**: Database changes trigger NOTIFY → server forwards atomic eventsclient applies patches locally
23
+
24
+ > **Note**: DZQL uses [Atomic Updates](./atomic-updates.md) for efficient real-time sync. Instead of re-querying the full document on every change, the server forwards the raw event and the client patches its local copy. This reduces network traffic and preserves client-side UI state.
23
25
 
24
26
  ## Quick Start
25
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",