@statezero/core 0.2.42 → 0.2.44

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.
@@ -32,6 +32,9 @@ export function useQueryset(querysetFactory) {
32
32
  updateSyncManager();
33
33
  lastQueryset = queryset;
34
34
  }
35
+ // Access __version to establish Vue dependency tracking for watch()
36
+ // This makes the computed re-evaluate when queryset data changes
37
+ const _ = result?.__version;
35
38
  return result;
36
39
  });
37
40
  }
@@ -1,3 +1,56 @@
1
+ /**
2
+ * Vue Reactivity Adapters for StateZero
3
+ *
4
+ * This module bridges StateZero's event-based reactivity (mitt) with Vue's reactivity system.
5
+ *
6
+ * ## How It Works
7
+ *
8
+ * StateZero emits events via mitt when data changes. These adapters:
9
+ * 1. Wrap data in Vue's reactive() or ref()
10
+ * 2. Listen for mitt events
11
+ * 3. Update the reactive wrapper when events fire
12
+ *
13
+ * ## Queryset Reactivity
14
+ *
15
+ * Querysets are wrapped as **stable reactive arrays**. The same object reference is
16
+ * maintained across updates - data is mutated in place via splice/push. This is
17
+ * intentional:
18
+ *
19
+ * - Templates automatically re-render (Vue tracks array mutations)
20
+ * - Object identity stays stable (no stale references in UI code)
21
+ * - Cached wrappers are reused for the same queryset
22
+ *
23
+ * ### Watching Querysets
24
+ *
25
+ * Because the object reference is stable, Vue's shallow watch won't detect changes.
26
+ * Use one of these patterns:
27
+ *
28
+ * ```js
29
+ * const messages = useQueryset(() => baseQs.value.fetch())
30
+ *
31
+ * // Option 1: Deep watch (recommended)
32
+ * watch(messages, (newMessages) => {
33
+ * scrollToBottom()
34
+ * }, { deep: true })
35
+ *
36
+ * // Option 2: Watch a derived value
37
+ * watch(() => messages.value.length, (newLen) => {
38
+ * scrollToBottom()
39
+ * })
40
+ * ```
41
+ *
42
+ * ### The __version Mechanism
43
+ *
44
+ * Each queryset wrapper has a `__version` counter that increments on every update.
45
+ * The `useQueryset` composable accesses this to establish Vue dependency tracking,
46
+ * ensuring the computed re-evaluates when data changes. This makes `{ deep: true }`
47
+ * watches work correctly.
48
+ *
49
+ * ## Model Reactivity
50
+ *
51
+ * Models use a similar `__version` / `touch()` mechanism. When a model is updated,
52
+ * `touch()` increments the version, triggering Vue to re-render dependent components.
53
+ */
1
54
  import { reactive, ref, nextTick } from "vue";
2
55
  import { modelEventEmitter, querysetEventEmitter, metricEventEmitter } from "../../syncEngine/stores/reactivity.js";
3
56
  import { initEventHandler } from "../../syncEngine/stores/operationEventHandlers.js";
@@ -66,6 +119,7 @@ export function QuerySetAdaptor(liveQuerySet, reactivityFn = reactive) {
66
119
  // Make the queryset reactive using the specified function
67
120
  const wrapper = reactivityFn([...liveQuerySet]);
68
121
  wrapper.original = liveQuerySet;
122
+ wrapper.__version = 0;
69
123
  const eventName = `${configKey}::${modelName}::queryset::render`;
70
124
  // Handler bumps version to trigger Vue reactivity when this queryset updates
71
125
  const renderHandler = (eventData) => {
@@ -77,6 +131,8 @@ export function QuerySetAdaptor(liveQuerySet, reactivityFn = reactive) {
77
131
  wrapper.splice(0, wrapper.length);
78
132
  wrapper.push(...liveQuerySet);
79
133
  }
134
+ // Bump version so computed/watch can track changes
135
+ wrapper.__version++;
80
136
  }
81
137
  };
82
138
  // Subscribe to queryset events indefinitely
@@ -17,6 +17,7 @@ import { processQuery, getRequiredFields, pickRequiredFields } from '../../filte
17
17
  import { filter } from '../../filtering/localFiltering.js';
18
18
  import { makeApiCall } from '../../flavours/django/makeApiCall.js';
19
19
  import { QuerysetStoreGraph } from './querysetStoreGraph.js';
20
+ import { getConfig } from '../../config.js';
20
21
  import { isNil, pick } from 'lodash-es';
21
22
  import hash from 'object-hash';
22
23
  import { Operation } from '../stores/operation.js';
@@ -291,10 +292,14 @@ export class QuerysetStoreRegistry {
291
292
  return;
292
293
  const semanticKey = queryset.semanticKey;
293
294
  const ModelClass = queryset.ModelClass;
294
- // Convert dbSyncedKeys to semanticKeys if needed
295
+ // Convert dbSyncedKeys to semanticKeys, filtering to only keys that have stores
296
+ // A key without a store can never be a valid root (nobody would sync it)
295
297
  const subset = new Set();
296
298
  for (const item of dbSyncedKeys) {
297
- subset.add(typeof item === 'string' ? item : item?.semanticKey);
299
+ const key = typeof item === 'string' ? item : item?.semanticKey;
300
+ if (key && this._stores.has(key)) {
301
+ subset.add(key);
302
+ }
298
303
  }
299
304
  // Find the dbSynced root
300
305
  const { isRoot, root: rootKey } = this.querysetStoreGraph.findRoot(queryset, subset);
@@ -319,8 +324,34 @@ export class QuerysetStoreRegistry {
319
324
  cached.resolve();
320
325
  }
321
326
  else {
322
- // Wait for root to finish
323
- await cached.promise;
327
+ // Wait for root to finish with timeout to prevent deadlocks
328
+ // Use 2x periodic sync interval (same as SyncManager.withTimeout) or 30s fallback
329
+ let syncTimeoutMs = 30000;
330
+ try {
331
+ const config = getConfig();
332
+ if (config.periodicSyncIntervalSeconds) {
333
+ syncTimeoutMs = config.periodicSyncIntervalSeconds * 2000;
334
+ }
335
+ }
336
+ catch {
337
+ // Use default if no config
338
+ }
339
+ const timeoutPromise = new Promise((_, reject) => {
340
+ setTimeout(() => reject(new Error('timeout')), syncTimeoutMs);
341
+ });
342
+ let timedOut = false;
343
+ try {
344
+ await Promise.race([cached.promise, timeoutPromise]);
345
+ }
346
+ catch {
347
+ timedOut = true;
348
+ }
349
+ // Fallback to direct sync on timeout or if root data is missing
350
+ if (timedOut || !cached.pks) {
351
+ console.warn(`[groupSync] Falling back to direct sync for: ${semanticKey.substring(0, 60)}`);
352
+ await store.sync();
353
+ return;
354
+ }
324
355
  // Filter from cached root data
325
356
  const rootInstances = cached.pks.map(pk => ModelClass.fromPk(pk, queryset));
326
357
  const ast = queryset.build();
@@ -161,8 +161,10 @@ export class SyncManager {
161
161
  return;
162
162
  // Generate operationId for this sync batch - querysets in same chain will coordinate
163
163
  const operationId = `periodic-sync-${uuidv7()}`;
164
- // Get dbSynced keys (followed querysets)
165
- const dbSyncedKeys = new Set([...this.followedQuerysets].map(qs => qs.semanticKey));
164
+ // Get dbSynced keys (followed querysets that have stores)
165
+ const dbSyncedKeys = new Set([...this.followedQuerysets]
166
+ .map(qs => qs.semanticKey)
167
+ .filter(key => querysetRegistry._stores.has(key)));
166
168
  // Collect all stores to sync
167
169
  const storesToSync = [];
168
170
  for (const [semanticKey, store] of querysetRegistry._stores.entries()) {
@@ -266,7 +268,9 @@ export class SyncManager {
266
268
  return;
267
269
  console.log(`[SyncManager] Syncing ${storesToSync.length} querysets needing verification for operation ${operationId}`);
268
270
  const syncOperationId = `maybe-sync-${uuidv7()}`;
269
- const dbSyncedKeys = new Set([...this.followedQuerysets].map(qs => qs.semanticKey));
271
+ const dbSyncedKeys = new Set([...this.followedQuerysets]
272
+ .map(qs => qs.semanticKey)
273
+ .filter(key => registry._stores.has(key)));
270
274
  Promise.all(storesToSync.map(store => registry.groupSync(store.queryset, syncOperationId, dbSyncedKeys)));
271
275
  }
272
276
  processBatch(reason = "unknown") {
@@ -345,8 +349,12 @@ export class SyncManager {
345
349
  console.log(`[SyncManager] Syncing ${storesToSync.length} queryset stores for ${representativeEvent.model}`);
346
350
  // Generate operationId for this batch - querysets in same chain will coordinate
347
351
  const operationId = `remote-event-${uuidv7()}`;
348
- // Get dbSynced keys (followed querysets)
349
- const dbSyncedKeys = new Set([...this.followedQuerysets].map(qs => qs.semanticKey));
352
+ // Get dbSynced keys (followed querysets that have stores)
353
+ // Filter to only include keys with stores - a queryset can be followed but not have
354
+ // a store if useQueryset doesn't call .fetch() (e.g., base queryset in a chain)
355
+ const dbSyncedKeys = new Set([...this.followedQuerysets]
356
+ .map(qs => qs.semanticKey)
357
+ .filter(key => registry._stores.has(key)));
350
358
  // Run all groupSync calls in parallel - they coordinate via shared promise cache
351
359
  Promise.all(storesToSync.map(store => registry.groupSync(store.queryset, operationId, dbSyncedKeys)));
352
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
4
4
  "type": "module",
5
5
  "module": "ESNext",
6
6
  "description": "The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate",
@@ -115,8 +115,10 @@
115
115
  "@types/yargs": "^17.0.32",
116
116
  "@vitejs/plugin-vue": "^6.0.4",
117
117
  "@vitest/coverage-v8": "^3.0.5",
118
+ "@vue/test-utils": "^2.4.6",
118
119
  "fake-indexeddb": "^6.0.0",
119
120
  "fast-glob": "^3.3.3",
121
+ "happy-dom": "^20.5.0",
120
122
  "react": "^18.2.0",
121
123
  "rimraf": "^5.0.5",
122
124
  "ts-node": "^10.9.2",