dzql 0.1.5 → 0.2.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.
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
+ node packages/dzql/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.
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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",
7
7
  "exports": {
8
8
  ".": "./src/server/index.js",
9
9
  "./client": "./src/client/ws.js",
10
+ "./client/stores": "./src/client/stores/index.js",
11
+ "./client/templates": "./src/client/templates/App.vue",
10
12
  "./server": "./src/server/index.js",
11
13
  "./db": "./src/server/db.js",
12
14
  "./compiler": "./src/compiler/index.js"
@@ -20,12 +22,13 @@
20
22
  ],
21
23
  "scripts": {
22
24
  "test": "bun test",
23
- "prepublishOnly": "echo '✅ Publishing DZQL v0.1.5...'"
25
+ "prepublishOnly": "echo '✅ Publishing DZQL v0.2.0...'"
24
26
  },
25
27
  "dependencies": {
26
28
  "jose": "^6.1.0",
27
29
  "postgres": "^3.4.7"
28
30
  },
31
+
29
32
  "keywords": [
30
33
  "postgresql",
31
34
  "postgres",
@@ -0,0 +1,95 @@
1
+ # DZQL Canonical Pinia Stores
2
+
3
+ **The official, AI-friendly Pinia stores for DZQL Vue.js applications.**
4
+
5
+ ## Why These Stores Exist
6
+
7
+ When building DZQL apps, developers (and AI assistants) often struggle with:
8
+
9
+ 1. **Three-phase lifecycle** - connecting → login → ready
10
+ 2. **WebSocket connection management** - reconnection, error handling
11
+ 3. **Authentication flow** - token storage, profile management
12
+ 4. **Router integration** - navigation, state synchronization
13
+ 5. **Inconsistent patterns** - every project does it differently
14
+
15
+ These canonical stores solve all of these problems with a **simple, consistent pattern** that AI can easily understand and replicate.
16
+
17
+ ## The Stores
18
+
19
+ ### `useWsStore` - WebSocket & Auth
20
+
21
+ Manages:
22
+ - WebSocket connection (with auto-reconnect)
23
+ - User authentication (login/register/logout)
24
+ - Connection state tracking
25
+ - Three-phase app lifecycle
26
+
27
+ ### `useAppStore` - Application State
28
+
29
+ Manages:
30
+ - App initialization
31
+ - Router integration
32
+ - Entity metadata caching
33
+ - Navigation helpers
34
+ - UI state (sidebars, panels)
35
+
36
+ ## Quick Example
37
+
38
+ ```vue
39
+ <script setup>
40
+ import { computed } from 'vue'
41
+ import { useWsStore, useAppStore } from 'dzql/client/stores'
42
+
43
+ const wsStore = useWsStore()
44
+ const appStore = useAppStore()
45
+
46
+ const state = computed(() => wsStore.appState)
47
+ const ws = wsStore.getWs()
48
+
49
+ // Use DZQL API
50
+ const venues = await ws.api.search.venues({ limit: 50 })
51
+ </script>
52
+
53
+ <template>
54
+ <!-- Three-phase lifecycle -->
55
+ <div v-if="state === 'connecting'">Connecting...</div>
56
+ <LoginView v-else-if="state === 'login'" />
57
+ <MainApp v-else-if="state === 'ready'" />
58
+ </template>
59
+ ```
60
+
61
+ ## Documentation
62
+
63
+ - **[Quick Start](../../../docs/CLIENT-QUICK-START.md)** - Get running in 5 minutes
64
+ - **[Complete Guide](../../../docs/CLIENT-STORES.md)** - Full API reference and patterns
65
+
66
+ ## Files
67
+
68
+ - `useWsStore.js` - WebSocket connection and authentication
69
+ - `useAppStore.js` - Application state and navigation
70
+ - `index.js` - Exports both stores
71
+
72
+ ## For AI Assistants
73
+
74
+ When helping users with DZQL apps:
75
+
76
+ 1. **Always use these stores** - Don't create custom connection logic
77
+ 2. **Follow the three-phase lifecycle** - connecting → login → ready
78
+ 3. **Use computed for reactive state** - `const profile = computed(() => wsStore.profile)`
79
+ 4. **Get WS instance for API calls** - `const ws = wsStore.getWs()`
80
+
81
+ **Example prompt for AI:**
82
+
83
+ > "I'm using the canonical DZQL stores from `dzql/client/stores`. The pattern is:
84
+ > 1. useWsStore for WebSocket connection (three phases: connecting, login, ready)
85
+ > 2. useAppStore for app state and navigation
86
+ > 3. Access DZQL API via `wsStore.getWs().api.get.venues({ id: 1 })`
87
+ > Please follow this pattern."
88
+
89
+ ## Version
90
+
91
+ These stores are available in DZQL v0.1.6+
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,8 @@
1
+ /**
2
+ * DZQL Client Stores
3
+ *
4
+ * Canonical Pinia stores for DZQL applications
5
+ */
6
+
7
+ export { useWsStore } from './useWsStore.js'
8
+ export { useAppStore } from './useAppStore.js'
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Canonical DZQL App Pinia Store
3
+ *
4
+ * Manages application-level state including:
5
+ * - App initialization
6
+ * - Router integration
7
+ * - Global UI state
8
+ * - Entity metadata caching
9
+ *
10
+ * Works with useWsStore to provide complete app lifecycle management.
11
+ *
12
+ * @example
13
+ * // In main.js
14
+ * import { createApp } from 'vue'
15
+ * import { createPinia } from 'pinia'
16
+ * import { useAppStore } from 'dzql/client/stores'
17
+ * import App from './App.vue'
18
+ *
19
+ * const pinia = createPinia()
20
+ * const app = createApp(App)
21
+ * app.use(pinia)
22
+ *
23
+ * const appStore = useAppStore()
24
+ * await appStore.initialize()
25
+ *
26
+ * app.mount('#app')
27
+ */
28
+ import { defineStore } from 'pinia'
29
+ import { ref, computed } from 'vue'
30
+ import { useWsStore } from './useWsStore.js'
31
+
32
+ export const useAppStore = defineStore('dzql-app', () => {
33
+ // ===== State =====
34
+
35
+ /**
36
+ * App title
37
+ */
38
+ const title = ref('DZQL App')
39
+
40
+ /**
41
+ * Current route/entity context
42
+ */
43
+ const currentEntity = ref(null)
44
+ const currentId = ref(null)
45
+
46
+ /**
47
+ * Entity metadata cache
48
+ * Maps entity name -> metadata object
49
+ */
50
+ const entityMetadata = ref({})
51
+
52
+ /**
53
+ * Loading states
54
+ */
55
+ const isLoadingMetadata = ref(false)
56
+
57
+ /**
58
+ * UI state
59
+ */
60
+ const sidebarOpen = ref(true)
61
+ const propertiesPanelOpen = ref(true)
62
+
63
+ /**
64
+ * Router instance (set via setRouter)
65
+ */
66
+ let routerInstance = null
67
+
68
+ // ===== Computed =====
69
+
70
+ const hasMetadata = computed(() => Object.keys(entityMetadata.value).length > 0)
71
+
72
+ const entityList = computed(() => {
73
+ return Object.keys(entityMetadata.value).sort()
74
+ })
75
+
76
+ const currentEntityMeta = computed(() => {
77
+ if (!currentEntity.value) return null
78
+ return entityMetadata.value[currentEntity.value] || null
79
+ })
80
+
81
+ // ===== Actions =====
82
+
83
+ /**
84
+ * Initialize the app
85
+ *
86
+ * Connects to WebSocket and sets up app lifecycle.
87
+ *
88
+ * @param {Object} options
89
+ * @param {string} [options.wsUrl] - WebSocket URL (auto-detected if not provided)
90
+ * @param {string} [options.title] - App title
91
+ * @returns {Promise<void>}
92
+ *
93
+ * @example
94
+ * await appStore.initialize()
95
+ *
96
+ * @example
97
+ * await appStore.initialize({
98
+ * wsUrl: 'ws://localhost:3000/ws',
99
+ * title: 'My DZQL App'
100
+ * })
101
+ */
102
+ async function initialize(options = {}) {
103
+ const wsStore = useWsStore()
104
+
105
+ // Set app title if provided
106
+ if (options.title) {
107
+ title.value = options.title
108
+ }
109
+
110
+ // Connect to WebSocket
111
+ await wsStore.connect(options.wsUrl)
112
+
113
+ // If authenticated, fetch metadata
114
+ if (wsStore.isAuthenticated) {
115
+ await fetchMetadata()
116
+ }
117
+
118
+ console.log('[AppStore] Initialized')
119
+ }
120
+
121
+ /**
122
+ * Fetch entity metadata from server
123
+ *
124
+ * Called automatically after authentication.
125
+ *
126
+ * @returns {Promise<void>}
127
+ */
128
+ async function fetchMetadata() {
129
+ const wsStore = useWsStore()
130
+ const ws = wsStore.getWs()
131
+
132
+ if (!wsStore.isConnected) {
133
+ console.warn('[AppStore] Cannot fetch metadata: not connected')
134
+ return
135
+ }
136
+
137
+ try {
138
+ isLoadingMetadata.value = true
139
+
140
+ // Call the meta endpoint
141
+ const result = await ws.call('meta')
142
+
143
+ if (result && result.entities) {
144
+ entityMetadata.value = result.entities
145
+ console.log('[AppStore] Metadata loaded:', Object.keys(result.entities))
146
+ }
147
+
148
+ } catch (err) {
149
+ console.error('[AppStore] Failed to fetch metadata:', err)
150
+ } finally {
151
+ isLoadingMetadata.value = false
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Set the router instance
157
+ *
158
+ * Call this in main.js after creating the router to enable
159
+ * programmatic navigation from the store.
160
+ *
161
+ * @param {Router} router - Vue Router instance
162
+ *
163
+ * @example
164
+ * import { createRouter } from 'vue-router'
165
+ * import { useAppStore } from 'dzql/client/stores'
166
+ *
167
+ * const router = createRouter({ ... })
168
+ * const appStore = useAppStore()
169
+ * appStore.setRouter(router)
170
+ */
171
+ function setRouter(router) {
172
+ routerInstance = router
173
+
174
+ // Watch route changes to update current context
175
+ if (router) {
176
+ router.afterEach((to) => {
177
+ currentEntity.value = to.params.entity || null
178
+ currentId.value = to.params.id || null
179
+ })
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Navigate to entity list
185
+ *
186
+ * @param {string} entity - Entity name
187
+ *
188
+ * @example
189
+ * appStore.navigateToEntity('venues')
190
+ */
191
+ function navigateToEntity(entity) {
192
+ if (routerInstance) {
193
+ routerInstance.push({ name: 'entity-list', params: { entity } })
194
+ }
195
+ currentEntity.value = entity
196
+ currentId.value = null
197
+ }
198
+
199
+ /**
200
+ * Navigate to entity detail/edit
201
+ *
202
+ * @param {string} entity - Entity name
203
+ * @param {number|string} id - Record ID or 'new'
204
+ *
205
+ * @example
206
+ * appStore.navigateToEntityDetail('venues', 123)
207
+ * appStore.navigateToEntityDetail('venues', 'new')
208
+ */
209
+ function navigateToEntityDetail(entity, id) {
210
+ if (routerInstance) {
211
+ const routeName = id === 'new' ? 'entity-create' : 'entity-edit'
212
+ routerInstance.push({ name: routeName, params: { entity, id } })
213
+ }
214
+ currentEntity.value = entity
215
+ currentId.value = id
216
+ }
217
+
218
+ /**
219
+ * Navigate to home
220
+ *
221
+ * @example
222
+ * appStore.navigateToHome()
223
+ */
224
+ function navigateToHome() {
225
+ if (routerInstance) {
226
+ routerInstance.push({ name: 'home' })
227
+ }
228
+ currentEntity.value = null
229
+ currentId.value = null
230
+ }
231
+
232
+ /**
233
+ * Toggle sidebar
234
+ */
235
+ function toggleSidebar() {
236
+ sidebarOpen.value = !sidebarOpen.value
237
+ }
238
+
239
+ /**
240
+ * Toggle properties panel
241
+ */
242
+ function togglePropertiesPanel() {
243
+ propertiesPanelOpen.value = !propertiesPanelOpen.value
244
+ }
245
+
246
+ /**
247
+ * Set current context (useful for manual navigation)
248
+ *
249
+ * @param {string|null} entity - Entity name
250
+ * @param {number|string|null} id - Record ID
251
+ */
252
+ function setContext(entity, id = null) {
253
+ currentEntity.value = entity
254
+ currentId.value = id
255
+ }
256
+
257
+ // ===== Return Public API =====
258
+
259
+ return {
260
+ // State
261
+ title,
262
+ currentEntity,
263
+ currentId,
264
+ entityMetadata,
265
+ isLoadingMetadata,
266
+ sidebarOpen,
267
+ propertiesPanelOpen,
268
+
269
+ // Computed
270
+ hasMetadata,
271
+ entityList,
272
+ currentEntityMeta,
273
+
274
+ // Actions
275
+ initialize,
276
+ fetchMetadata,
277
+ setRouter,
278
+ navigateToEntity,
279
+ navigateToEntityDetail,
280
+ navigateToHome,
281
+ toggleSidebar,
282
+ togglePropertiesPanel,
283
+ setContext
284
+ }
285
+ })