dzql 0.1.6 → 0.2.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.
@@ -0,0 +1,488 @@
1
+ # Live Query Subscriptions Strategy
2
+
3
+ **Date:** 2025-11-16
4
+ **Status:** Phase 1 Complete (Compiler), Phase 2-4 Pending
5
+
6
+ ---
7
+
8
+ ## Overview
9
+
10
+ Live Query Subscriptions implement **Pattern 1** from `vision.md` - allowing clients to subscribe to denormalized documents and receive automatic updates when any related data changes.
11
+
12
+ ### Architecture Principles
13
+
14
+ 1. **PostgreSQL-First**: Database determines which subscriptions are affected
15
+ 2. **Compiler-Driven**: All logic compiled to PostgreSQL functions (zero runtime interpretation)
16
+ 3. **In-Memory Subscriptions**: Server holds active subscriptions in memory for performance
17
+ 4. **Naming Convention**: `subscribe_<name>` / `unsubscribe_<name>` for pattern matching
18
+
19
+ ---
20
+
21
+ ## Core Concept: Subscribables
22
+
23
+ **Subscribables** are separate from entities - they define denormalized documents that:
24
+ - Combine data from multiple entities (root + relations)
25
+ - Have their own access control (permission paths)
26
+ - Define subscription parameters (subscription key)
27
+ - Compile to PostgreSQL functions that determine affected documents
28
+
29
+ ### Example Subscribable
30
+
31
+ ```sql
32
+ SELECT dzql.register_subscribable(
33
+ 'venue_detail', -- Subscribable name
34
+
35
+ -- Permission: who can subscribe?
36
+ jsonb_build_object(
37
+ 'subscribe', ARRAY['@org_id->acts_for[org_id=$]{active}.user_id']
38
+ ),
39
+
40
+ -- Parameters: subscription key
41
+ jsonb_build_object(
42
+ 'venue_id', 'int'
43
+ ),
44
+
45
+ -- Root entity
46
+ 'venues',
47
+
48
+ -- Relations to include
49
+ jsonb_build_object(
50
+ 'org', 'organisations',
51
+ 'sites', jsonb_build_object(
52
+ 'entity', 'sites',
53
+ 'filter', 'venue_id=$venue_id'
54
+ ),
55
+ 'packages', jsonb_build_object(
56
+ 'entity', 'packages',
57
+ 'filter', 'venue_id=$venue_id',
58
+ 'include', jsonb_build_object(
59
+ 'allocations', 'allocations'
60
+ )
61
+ )
62
+ )
63
+ );
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Generated PostgreSQL Functions
69
+
70
+ For each subscribable, the compiler generates 3 functions:
71
+
72
+ ###1. Access Control: `<name>_can_subscribe(user_id, params)`
73
+
74
+ ```sql
75
+ CREATE OR REPLACE FUNCTION venue_detail_can_subscribe(
76
+ p_user_id INT,
77
+ p_params JSONB
78
+ ) RETURNS BOOLEAN AS $$
79
+ BEGIN
80
+ -- Check permission path: @org_id->acts_for[org_id=$]{active}.user_id
81
+ RETURN EXISTS (
82
+ SELECT 1
83
+ FROM venues v
84
+ JOIN acts_for af ON af.org_id = v.org_id
85
+ WHERE v.id = (p_params->>'venue_id')::int
86
+ AND af.user_id = p_user_id
87
+ AND af.valid_to IS NULL
88
+ );
89
+ END;
90
+ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
91
+ ```
92
+
93
+ ### 2. Query Function: `get_<name>(params, user_id)`
94
+
95
+ ```sql
96
+ CREATE OR REPLACE FUNCTION get_venue_detail(
97
+ p_params JSONB,
98
+ p_user_id INT
99
+ ) RETURNS JSONB AS $$
100
+ DECLARE
101
+ v_venue_id int;
102
+ v_result JSONB;
103
+ BEGIN
104
+ v_venue_id := (p_params->>'venue_id')::int;
105
+
106
+ -- Check access control
107
+ IF NOT venue_detail_can_subscribe(p_user_id, p_params) THEN
108
+ RAISE EXCEPTION 'Permission denied';
109
+ END IF;
110
+
111
+ -- Build document with root and all relations
112
+ SELECT jsonb_build_object(
113
+ 'venues', row_to_json(root.*),
114
+ 'org', (SELECT row_to_json(o.*) FROM organisations o WHERE o.id = root.org_id),
115
+ 'sites', (SELECT jsonb_agg(s.*) FROM sites s WHERE s.venue_id = root.id),
116
+ 'packages', (
117
+ SELECT jsonb_agg(
118
+ jsonb_build_object(
119
+ 'package', row_to_json(p.*),
120
+ 'allocations', (SELECT jsonb_agg(a.*) FROM allocations a WHERE a.package_id = p.id)
121
+ )
122
+ )
123
+ FROM packages p WHERE p.venue_id = root.id
124
+ )
125
+ )
126
+ INTO v_result
127
+ FROM venues root
128
+ WHERE root.id = v_venue_id;
129
+
130
+ RETURN v_result;
131
+ END;
132
+ $$ LANGUAGE plpgsql SECURITY DEFINER;
133
+ ```
134
+
135
+ ### 3. Affected Documents: `<name>_affected_documents(table, op, old, new)`
136
+
137
+ ```sql
138
+ CREATE OR REPLACE FUNCTION venue_detail_affected_documents(
139
+ p_table_name TEXT,
140
+ p_op TEXT,
141
+ p_old JSONB,
142
+ p_new JSONB
143
+ ) RETURNS JSONB[] AS $$
144
+ DECLARE
145
+ v_affected JSONB[];
146
+ BEGIN
147
+ CASE p_table_name
148
+ -- Venue changed: affects subscription for that venue
149
+ WHEN 'venues' THEN
150
+ v_affected := ARRAY[
151
+ jsonb_build_object('venue_id', COALESCE(p_new->>'id', p_old->>'id')::int)
152
+ ];
153
+
154
+ -- Organisation changed: affects all venues in that org
155
+ WHEN 'organisations' THEN
156
+ SELECT ARRAY_AGG(jsonb_build_object('venue_id', v.id))
157
+ INTO v_affected
158
+ FROM venues v
159
+ WHERE v.org_id = COALESCE((p_new->>'id')::int, (p_old->>'id')::int);
160
+
161
+ -- Site changed: affects parent venue
162
+ WHEN 'sites' THEN
163
+ v_affected := ARRAY[
164
+ jsonb_build_object('venue_id', COALESCE(p_new->>'venue_id', p_old->>'venue_id')::int)
165
+ ];
166
+
167
+ -- Package changed: affects parent venue
168
+ WHEN 'packages' THEN
169
+ v_affected := ARRAY[
170
+ jsonb_build_object('venue_id', COALESCE(p_new->>'venue_id', p_old->>'venue_id')::int)
171
+ ];
172
+
173
+ -- Allocation changed: affects venue via package
174
+ WHEN 'allocations' THEN
175
+ SELECT ARRAY_AGG(jsonb_build_object('venue_id', p.venue_id))
176
+ INTO v_affected
177
+ FROM packages p
178
+ WHERE p.id = COALESCE((p_new->>'package_id')::int, (p_old->>'package_id')::int);
179
+
180
+ ELSE
181
+ v_affected := ARRAY[]::JSONB[];
182
+ END CASE;
183
+
184
+ RETURN v_affected;
185
+ END;
186
+ $$ LANGUAGE plpgsql IMMUTABLE;
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Server Implementation (In-Memory)
192
+
193
+ ### Subscription Registry
194
+
195
+ ```javascript
196
+ // In-memory storage
197
+ const subscriptions = new Map();
198
+ // subscription_id -> { subscribable, user_id, connection_id, params }
199
+
200
+ const connectionSubscriptions = new Map();
201
+ // connection_id -> Set<subscription_id>
202
+ ```
203
+
204
+ ### RPC Handlers
205
+
206
+ ```javascript
207
+ // Pattern matching on method names
208
+ if (method.startsWith('subscribe_')) {
209
+ const subscribableName = method.replace('subscribe_', '');
210
+ const subscriptionId = crypto.randomUUID();
211
+
212
+ // Execute initial query (checks permissions)
213
+ const data = await db.query(
214
+ `SELECT get_${subscribableName}($1, $2) as data`,
215
+ [params, userId]
216
+ );
217
+
218
+ // Store in memory
219
+ subscriptions.set(subscriptionId, {
220
+ subscribable: subscribableName,
221
+ user_id: userId,
222
+ connection_id: connectionId,
223
+ params
224
+ });
225
+
226
+ return {
227
+ subscription_id: subscriptionId,
228
+ data: data.rows[0].data
229
+ };
230
+ }
231
+
232
+ if (method.startsWith('unsubscribe_')) {
233
+ // Remove from in-memory registry
234
+ // Find and delete by params + connection
235
+ }
236
+ ```
237
+
238
+ ### Event Listener
239
+
240
+ ```javascript
241
+ setupListeners(async (event) => {
242
+ const { table, op, before, after } = event;
243
+
244
+ // EXISTING: Pattern 2 - Need to Know notifications
245
+ broadcast(...);
246
+
247
+ // NEW: Pattern 1 - Live Query subscriptions
248
+
249
+ // Group subscriptions by subscribable name
250
+ const subsByName = new Map();
251
+ for (const [subId, sub] of subscriptions.entries()) {
252
+ if (!subsByName.has(sub.subscribable)) {
253
+ subsByName.set(sub.subscribable, []);
254
+ }
255
+ subsByName.get(sub.subscribable).push({ subId, ...sub });
256
+ }
257
+
258
+ // For each subscribable, ask PostgreSQL which instances are affected
259
+ for (const [subscribableName, subs] of subsByName.entries()) {
260
+ const result = await db.query(
261
+ `SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
262
+ [table, op, before, after]
263
+ );
264
+
265
+ const affectedParamSets = result.rows[0]?.affected || [];
266
+
267
+ // Match affected params to active subscriptions (in-memory)
268
+ for (const affectedParams of affectedParamSets) {
269
+ for (const sub of subs) {
270
+ if (paramsMatch(sub.params, affectedParams)) {
271
+ // Re-execute query
272
+ const updated = await db.query(
273
+ `SELECT get_${subscribableName}($1, $2) as data`,
274
+ [sub.params, sub.user_id]
275
+ );
276
+
277
+ // Broadcast to connection
278
+ broadcastToConnection(sub.connection_id, {
279
+ method: 'subscription:update',
280
+ params: {
281
+ subscription_id: sub.subId,
282
+ data: updated.rows[0].data
283
+ }
284
+ });
285
+ }
286
+ }
287
+ }
288
+ }
289
+ });
290
+ ```
291
+
292
+ ---
293
+
294
+ ## Client API
295
+
296
+ ```javascript
297
+ // Subscribe
298
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
299
+ { venue_id: 1 },
300
+ (updated) => {
301
+ console.log('Venue updated:', updated);
302
+ // Update UI
303
+ }
304
+ );
305
+
306
+ // Initial data available immediately
307
+ console.log('Initial:', data);
308
+
309
+ // Unsubscribe
310
+ unsubscribe();
311
+
312
+ // Or call directly
313
+ await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
314
+ ```
315
+
316
+ ---
317
+
318
+ ## Implementation Status
319
+
320
+ ### ✅ Phase 1: Compiler (COMPLETE)
321
+
322
+ **Files Created:**
323
+ - `/packages/dzql/src/compiler/codegen/subscribable-codegen.js` - Code generation
324
+ - `/packages/dzql/src/compiler/parser/subscribable-parser.js` - SQL parsing
325
+ - `/packages/dzql/src/compiler/compiler.js` - Extended with subscribable support
326
+
327
+ **Exports:**
328
+ - `compileSubscribable(subscribable)` - Compile single subscribable
329
+ - `compileAllSubscribables(subscribables[])` - Compile multiple
330
+ - `compileSubscribablesFromSQL(sqlContent)` - Parse and compile from SQL file
331
+
332
+ **Generated Functions:**
333
+ - `<name>_can_subscribe(user_id, params)` - Access control
334
+ - `get_<name>(params, user_id)` - Query function
335
+ - `<name>_affected_documents(table, op, old, new)` - Affected params
336
+
337
+ **Known Issue:**
338
+ - Parser needs improvement for nested `jsonb_build_object()` calls
339
+ - Test compilation failing on parameter splitting
340
+
341
+ ---
342
+
343
+ ### 🔨 Phase 2: Database Schema (TODO)
344
+
345
+ **Tasks:**
346
+ 1. Create `dzql.subscribables` table (metadata only)
347
+ 2. Create `register_subscribable()` SQL function
348
+ 3. Migration file: `011_subscriptions.sql`
349
+
350
+ **Schema:**
351
+ ```sql
352
+ CREATE TABLE IF NOT EXISTS dzql.subscribables (
353
+ name TEXT PRIMARY KEY,
354
+ permission_paths jsonb NOT NULL,
355
+ param_schema jsonb NOT NULL,
356
+ root_entity text NOT NULL,
357
+ relations jsonb NOT NULL,
358
+ created_at timestamptz DEFAULT NOW()
359
+ );
360
+ ```
361
+
362
+ ---
363
+
364
+ ### 🔨 Phase 3: Server Integration (TODO)
365
+
366
+ **Files to Modify:**
367
+ 1. `/packages/dzql/src/server/ws.js`
368
+ - Add in-memory subscription registry
369
+ - Add `subscribe_*` / `unsubscribe_*` handlers
370
+ - Add `broadcastToConnection()` function
371
+
372
+ 2. `/packages/dzql/src/server/index.js`
373
+ - Extend event listener to check affected subscriptions
374
+ - Re-execute queries and broadcast updates
375
+
376
+ **Estimated Effort:** 2-3 days
377
+
378
+ ---
379
+
380
+ ### 🔨 Phase 4: Client Integration (TODO)
381
+
382
+ **Files to Modify:**
383
+ 1. `/packages/dzql/src/client/ws.js`
384
+ - Add `subscribe_*` method handling
385
+ - Handle `subscription:update` messages
386
+ - Return `{ data, unsubscribe }` pattern
387
+
388
+ **Estimated Effort:** 1-2 days
389
+
390
+ ---
391
+
392
+ ## Testing Strategy
393
+
394
+ ### Unit Tests (Compiler)
395
+ ```javascript
396
+ test('generates subscribable functions', () => {
397
+ const result = compileSubscribable({
398
+ name: 'venue_detail',
399
+ permissionPaths: { subscribe: ['@org_id->acts_for...'] },
400
+ paramSchema: { venue_id: 'int' },
401
+ rootEntity: 'venues',
402
+ relations: { org: 'organisations', sites: 'sites' }
403
+ });
404
+
405
+ expect(result.sql).toContain('venue_detail_can_subscribe');
406
+ expect(result.sql).toContain('get_venue_detail');
407
+ expect(result.sql).toContain('venue_detail_affected_documents');
408
+ });
409
+ ```
410
+
411
+ ### Integration Tests (Database)
412
+ ```sql
413
+ -- Test affected documents function
414
+ SELECT venue_detail_affected_documents(
415
+ 'venues', 'update',
416
+ '{"id": 1, "name": "Old"}'::jsonb,
417
+ '{"id": 1, "name": "New"}'::jsonb
418
+ );
419
+ -- Should return: [{"venue_id": 1}]
420
+
421
+ -- Test query function
422
+ SELECT get_venue_detail('{"venue_id": 1}'::jsonb, 5);
423
+ -- Should return denormalized document
424
+ ```
425
+
426
+ ### E2E Tests
427
+ ```javascript
428
+ test('subscription receives updates', async () => {
429
+ const updates = [];
430
+
431
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
432
+ { venue_id: 1 },
433
+ (updated) => updates.push(updated)
434
+ );
435
+
436
+ // Trigger change
437
+ await ws.api.save.venues({ id: 1, name: 'Updated' });
438
+
439
+ await waitFor(() => updates.length > 0);
440
+ expect(updates[0].venues.name).toBe('Updated');
441
+
442
+ unsubscribe();
443
+ });
444
+ ```
445
+
446
+ ---
447
+
448
+ ## Next Steps
449
+
450
+ 1. **Fix Parser** - Handle nested `jsonb_build_object()` correctly
451
+ 2. **Test Compilation** - Verify generated SQL is correct
452
+ 3. **Create Migration** - `011_subscriptions.sql` with schema
453
+ 4. **Implement Server Handlers** - In-memory subscriptions + event processing
454
+ 5. **Implement Client Support** - `subscribe_*` methods
455
+ 6. **Integration Testing** - End-to-end subscription flow
456
+ 7. **Documentation** - API docs and examples
457
+
458
+ ---
459
+
460
+ ## Estimated Timeline
461
+
462
+ | Phase | Tasks | Effort |
463
+ |-------|-------|--------|
464
+ | Phase 1: Compiler | ✅ Complete | 4 hours |
465
+ | Phase 2: Database | Schema + migration | 1 day |
466
+ | Phase 3: Server | Handlers + event processing | 2-3 days |
467
+ | Phase 4: Client | Client API | 1-2 days |
468
+ | Testing | Unit + Integration + E2E | 2-3 days |
469
+ | **Total** | | **7-10 days** |
470
+
471
+ ---
472
+
473
+ ## Success Criteria
474
+
475
+ - ✅ Compiler generates 3 PostgreSQL functions per subscribable
476
+ - ⏳ PostgreSQL determines affected subscription instances (not server)
477
+ - ⏳ Server holds subscriptions in-memory (fast lookup)
478
+ - ⏳ Naming convention: `subscribe_<name>` / `unsubscribe_<name>`
479
+ - ⏳ Client receives automatic updates on data changes
480
+ - ⏳ < 100ms latency from DB change to client update
481
+ - ⏳ Supports 1000+ concurrent subscriptions per server
482
+ - ⏳ Zero runtime interpretation (all logic compiled)
483
+
484
+ ---
485
+
486
+ **Implementation by:** Claude Sonnet 4.5
487
+ **Project:** DZQL Live Query Subscriptions
488
+ **Version:** 0.1.4+subscriptions
package/docs/REFERENCE.md CHANGED
@@ -12,6 +12,7 @@ Complete API documentation for DZQL framework. For tutorials, see [GETTING_START
12
12
  - [Custom Functions](#custom-functions)
13
13
  - [Authentication](#authentication)
14
14
  - [Real-time Events](#real-time-events)
15
+ - [Live Query Subscriptions](#live-query-subscriptions)
15
16
  - [Temporal Relationships](#temporal-relationships)
16
17
  - [Error Messages](#error-messages)
17
18
 
@@ -864,6 +865,144 @@ ws.onBroadcast((method, params) => {
864
865
 
865
866
  ---
866
867
 
868
+ ## Live Query Subscriptions
869
+
870
+ Subscribe to denormalized documents and receive automatic updates when underlying data changes. Subscriptions use a PostgreSQL-first architecture where all change detection happens in the database.
871
+
872
+ For complete documentation, see **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** and **[Quick Start](../../../docs/SUBSCRIPTIONS_QUICK_START.md)**.
873
+
874
+ ### Quick Example
875
+
876
+ ```javascript
877
+ // Subscribe to venue with all related data
878
+ const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
879
+ { venue_id: 123 },
880
+ (updatedVenue) => {
881
+ // Called automatically when venue, org, or sites change
882
+ console.log('Updated:', updatedVenue);
883
+ // updatedVenue = { id: 123, name: '...', org: {...}, sites: [...] }
884
+ }
885
+ );
886
+
887
+ // Initial data available immediately
888
+ console.log('Initial:', data);
889
+
890
+ // Later: cleanup
891
+ await unsubscribe();
892
+ ```
893
+
894
+ ### Creating a Subscribable
895
+
896
+ Define subscribables in SQL:
897
+
898
+ ```sql
899
+ SELECT dzql.register_subscribable(
900
+ 'venue_detail', -- Name
901
+ '{"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]}'::jsonb, -- Permissions
902
+ '{"venue_id": "int"}'::jsonb, -- Parameters
903
+ 'venues', -- Root table
904
+ '{
905
+ "org": "organisations",
906
+ "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}
907
+ }'::jsonb -- Relations
908
+ );
909
+ ```
910
+
911
+ ### Compile and Deploy
912
+
913
+ ```bash
914
+ # Compile subscribable to PostgreSQL functions
915
+ bun packages/dzql/src/compiler/cli/compile-subscribable.js venue.sql | psql $DATABASE_URL
916
+ ```
917
+
918
+ This generates three functions:
919
+ - `venue_detail_can_subscribe(user_id, params)` - Permission check
920
+ - `get_venue_detail(params, user_id)` - Query builder
921
+ - `venue_detail_affected_documents(table, op, old, new)` - Change detector
922
+
923
+ ### Subscription Lifecycle
924
+
925
+ 1. **Subscribe**: Client calls `ws.api.subscribe_<name>(params, callback)`
926
+ 2. **Permission Check**: `<name>_can_subscribe()` validates access
927
+ 3. **Initial Query**: `get_<name>()` returns denormalized document
928
+ 4. **Register**: Server stores subscription in-memory
929
+ 5. **Database Change**: Any relevant table modification
930
+ 6. **Detect**: `<name>_affected_documents()` identifies affected subscriptions
931
+ 7. **Re-query**: `get_<name>()` fetches fresh data
932
+ 8. **Update**: Callback invoked with new data
933
+
934
+ ### Unsubscribe
935
+
936
+ ```javascript
937
+ // Method 1: Use returned unsubscribe function
938
+ const { unsubscribe } = await ws.api.subscribe_venue_detail(...);
939
+ await unsubscribe();
940
+
941
+ // Method 2: Direct unsubscribe call
942
+ await ws.api.unsubscribe_venue_detail({ venue_id: 123 });
943
+ ```
944
+
945
+ ### Architecture Benefits
946
+
947
+ - **PostgreSQL-First**: All logic executes in database, not application code
948
+ - **Zero Configuration**: Pattern matching on method names - no server changes needed
949
+ - **Type Safe**: Compiled functions validated at deploy time
950
+ - **Efficient**: In-memory registry, PostgreSQL does matching
951
+ - **Secure**: Permission paths enforced at database level
952
+ - **Scalable**: Stateless server, can add instances freely
953
+
954
+ ### Common Patterns
955
+
956
+ **Single Table:**
957
+ ```sql
958
+ SELECT dzql.register_subscribable(
959
+ 'user_settings',
960
+ '{"subscribe": ["@user_id"]}'::jsonb,
961
+ '{"user_id": "int"}'::jsonb,
962
+ 'user_settings',
963
+ '{}'::jsonb
964
+ );
965
+ ```
966
+
967
+ **With Relations:**
968
+ ```sql
969
+ SELECT dzql.register_subscribable(
970
+ 'booking_detail',
971
+ '{"subscribe": ["@user_id"]}'::jsonb,
972
+ '{"booking_id": "int"}'::jsonb,
973
+ 'bookings',
974
+ '{
975
+ "venue": "venues",
976
+ "customer": "users",
977
+ "items": {"entity": "booking_items", "filter": "booking_id=$booking_id"}
978
+ }'::jsonb
979
+ );
980
+ ```
981
+
982
+ **Multiple Permission Paths (OR logic):**
983
+ ```sql
984
+ SELECT dzql.register_subscribable(
985
+ 'venue_admin',
986
+ '{
987
+ "subscribe": [
988
+ "@owner_id",
989
+ "@org_id->acts_for[org_id=$]{active}.user_id"
990
+ ]
991
+ }'::jsonb,
992
+ '{"venue_id": "int"}'::jsonb,
993
+ 'venues',
994
+ '{"sites": {"entity": "sites", "filter": "venue_id=$venue_id"}}'::jsonb
995
+ );
996
+ ```
997
+
998
+ ### See Also
999
+
1000
+ - **[Live Query Subscriptions Guide](../../../docs/LIVE_QUERY_SUBSCRIPTIONS.md)** - Complete reference
1001
+ - **[Quick Start Guide](../../../docs/SUBSCRIPTIONS_QUICK_START.md)** - 5-minute tutorial
1002
+ - **[Permission Paths](#permission--notification-paths)** - Path DSL syntax
1003
+
1004
+ ---
1005
+
867
1006
  ## Temporal Relationships
868
1007
 
869
1008
  Handle time-based relationships with `valid_from`/`valid_to` fields.