@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
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/syncEngine/sync.js
CHANGED
|
@@ -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]
|
|
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]
|
|
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
|
-
|
|
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.
|
|
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",
|