@statezero/core 0.2.3 → 0.2.4
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/dist/adaptors/vue/composables.js +0 -12
- package/dist/core/eventReceivers.js +0 -15
- package/dist/flavours/django/querySet.js +1 -0
- package/dist/syncEngine/stores/querysetStore.d.ts +1 -1
- package/dist/syncEngine/stores/querysetStore.js +3 -10
- package/dist/syncEngine/sync.d.ts +0 -7
- package/dist/syncEngine/sync.js +2 -119
- package/package.json +1 -2
- package/dist/syncEngine/namespaceUtils.d.ts +0 -16
- package/dist/syncEngine/namespaceUtils.js +0 -32
|
@@ -9,18 +9,6 @@ export const querysets = new Map(); // Map of composableId -> queryset
|
|
|
9
9
|
function updateSyncManager() {
|
|
10
10
|
// Get unique querysets from all active composables
|
|
11
11
|
const uniqueQuerysets = new Set(querysets.values());
|
|
12
|
-
// Unsubscribe from querysets no longer followed
|
|
13
|
-
for (const [key, info] of syncManager.activeSubscriptions) {
|
|
14
|
-
if (!uniqueQuerysets.has(info.queryset)) {
|
|
15
|
-
syncManager.unsubscribeFromNamespace(info.queryset);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
// Subscribe to new querysets
|
|
19
|
-
for (const queryset of uniqueQuerysets) {
|
|
20
|
-
if (!syncManager.activeSubscriptions.has(queryset.semanticKey)) {
|
|
21
|
-
syncManager.subscribeToNamespace(queryset);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
12
|
syncManager.followedQuerysets = uniqueQuerysets;
|
|
25
13
|
}
|
|
26
14
|
export function useQueryset(querysetFactory) {
|
|
@@ -83,21 +83,6 @@ export class PusherEventReceiver {
|
|
|
83
83
|
clearTimeout(this.connectionTimeoutId);
|
|
84
84
|
this.connectionTimeoutId = null;
|
|
85
85
|
}
|
|
86
|
-
// Notify connected event
|
|
87
|
-
this.eventHandlers.forEach((handler) => {
|
|
88
|
-
if (handler.onReconnected) {
|
|
89
|
-
handler.onReconnected();
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
this.pusherClient.connection.bind("disconnected", () => {
|
|
94
|
-
console.log(`Pusher client disconnected for backend: ${this.configKey}.`);
|
|
95
|
-
// Notify disconnected event
|
|
96
|
-
this.eventHandlers.forEach((handler) => {
|
|
97
|
-
if (handler.onDisconnected) {
|
|
98
|
-
handler.onDisconnected();
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
86
|
});
|
|
102
87
|
this.pusherClient.connection.bind("failed", () => {
|
|
103
88
|
this._logConnectionError("Pusher connection explicitly failed.");
|
|
@@ -3,6 +3,7 @@ import { Model } from "./model.js";
|
|
|
3
3
|
import { ModelSerializer, relationshipFieldSerializer, dateFieldSerializer } from "./serializers.js";
|
|
4
4
|
import axios from "axios";
|
|
5
5
|
import { QueryExecutor } from "./queryExecutor.js";
|
|
6
|
+
import { json } from "stream/consumers";
|
|
6
7
|
import { v7 } from "uuid";
|
|
7
8
|
import hash from "object-hash";
|
|
8
9
|
import rfdc from "rfdc";
|
|
@@ -46,6 +46,6 @@ export class QuerysetStore {
|
|
|
46
46
|
renderFromRoot(optimistic: boolean | undefined, rootStore: any): any[];
|
|
47
47
|
renderFromData(optimistic?: boolean): any[];
|
|
48
48
|
applyOperation(operation: any, currentPks: any): any;
|
|
49
|
-
sync(
|
|
49
|
+
sync(forceFromDb?: boolean): Promise<void>;
|
|
50
50
|
}
|
|
51
51
|
import { Cache } from '../cache/cache.js';
|
|
@@ -269,8 +269,7 @@ export class QuerysetStore {
|
|
|
269
269
|
}
|
|
270
270
|
return currentPks;
|
|
271
271
|
}
|
|
272
|
-
async sync(
|
|
273
|
-
const { canonical_id, forceFromDb = false } = typeof options === 'boolean' ? { forceFromDb: options } : options;
|
|
272
|
+
async sync(forceFromDb = false) {
|
|
274
273
|
const id = this.modelClass.modelName;
|
|
275
274
|
if (this.isSyncing) {
|
|
276
275
|
console.warn(`[QuerysetStore ${id}] Already syncing, request ignored.`);
|
|
@@ -293,16 +292,10 @@ export class QuerysetStore {
|
|
|
293
292
|
this.isSyncing = true;
|
|
294
293
|
console.log(`[${id}] Starting sync...`);
|
|
295
294
|
try {
|
|
296
|
-
|
|
297
|
-
const requestBody = {
|
|
295
|
+
const response = await this.fetchFn({
|
|
298
296
|
ast: this.queryset.build(),
|
|
299
297
|
modelClass: this.modelClass,
|
|
300
|
-
};
|
|
301
|
-
// Include canonical_id if provided (for cache sharing)
|
|
302
|
-
if (canonical_id) {
|
|
303
|
-
requestBody.canonical_id = canonical_id;
|
|
304
|
-
}
|
|
305
|
-
const response = await this.fetchFn(requestBody);
|
|
298
|
+
});
|
|
306
299
|
const { data, included } = response;
|
|
307
300
|
if (isNil(data)) {
|
|
308
301
|
return;
|
|
@@ -5,7 +5,6 @@ export class EventPayload {
|
|
|
5
5
|
operation_id: any;
|
|
6
6
|
pk_field_name: any;
|
|
7
7
|
configKey: any;
|
|
8
|
-
canonical_id: any;
|
|
9
8
|
instances: any;
|
|
10
9
|
_cachedInstances: any;
|
|
11
10
|
get modelClass(): Function | null;
|
|
@@ -24,22 +23,16 @@ export class SyncManager {
|
|
|
24
23
|
maxWaitMs: number;
|
|
25
24
|
batchStartTime: number | null;
|
|
26
25
|
syncQueue: PQueue<import("p-queue/dist/priority-queue").default, import("p-queue").QueueAddOptions>;
|
|
27
|
-
activeSubscriptions: Map<any, any>;
|
|
28
|
-
currentCanonicalId: any;
|
|
29
26
|
withTimeout(promise: any, ms: any): Promise<any>;
|
|
30
27
|
/**
|
|
31
28
|
* Initialize event handlers for all event receivers
|
|
32
29
|
*/
|
|
33
30
|
initialize(): void;
|
|
34
|
-
handleDisconnect(): void;
|
|
35
|
-
handleReconnect(): void;
|
|
36
31
|
startPeriodicSync(): void;
|
|
37
32
|
syncStaleQuerysets(): void;
|
|
38
33
|
pruneUnreferencedModels(): void;
|
|
39
34
|
isStoreFollowed(registry: any, semanticKey: any): boolean;
|
|
40
35
|
cleanup(): void;
|
|
41
|
-
subscribeToNamespace(queryset: any): Promise<void>;
|
|
42
|
-
unsubscribeFromNamespace(queryset: any): Promise<void>;
|
|
43
36
|
followModel(registry: any, modelClass: any): void;
|
|
44
37
|
unfollowModel(registry: any, modelClass: any): void;
|
|
45
38
|
manageRegistry(registry: any): void;
|
package/dist/syncEngine/sync.js
CHANGED
|
@@ -16,7 +16,6 @@ export class EventPayload {
|
|
|
16
16
|
this.operation_id = data.operation_id;
|
|
17
17
|
this.pk_field_name = data.pk_field_name;
|
|
18
18
|
this.configKey = data.configKey;
|
|
19
|
-
this.canonical_id = data.canonical_id;
|
|
20
19
|
// Parse PK fields to numbers in instances
|
|
21
20
|
this.instances = data.instances?.map(instance => {
|
|
22
21
|
if (instance && this.pk_field_name && instance[this.pk_field_name] != null) {
|
|
@@ -51,10 +50,6 @@ export class SyncManager {
|
|
|
51
50
|
this.handleEvent = (event) => {
|
|
52
51
|
let payload = new EventPayload(event);
|
|
53
52
|
let isLocalOperation = operationRegistry.has(payload.operation_id);
|
|
54
|
-
// Store canonical_id for upcoming refetches
|
|
55
|
-
if (event.canonical_id) {
|
|
56
|
-
this.currentCanonicalId = event.canonical_id;
|
|
57
|
-
}
|
|
58
53
|
// Always process metrics immediately (they're lightweight)
|
|
59
54
|
if (this.registries.has(MetricRegistry)) {
|
|
60
55
|
this.processMetrics(payload);
|
|
@@ -102,9 +97,6 @@ export class SyncManager {
|
|
|
102
97
|
this.batchStartTime = null;
|
|
103
98
|
// SyncQueue
|
|
104
99
|
this.syncQueue = new PQueue({ concurrency: 1 });
|
|
105
|
-
// Namespace subscription tracking
|
|
106
|
-
this.activeSubscriptions = new Map(); // semanticKey -> {queryset, subscribedAt}
|
|
107
|
-
this.currentCanonicalId = null; // Store canonical_id from events
|
|
108
100
|
}
|
|
109
101
|
withTimeout(promise, ms) {
|
|
110
102
|
// If no timeout specified, use 2x the periodic sync interval, or 30s as fallback
|
|
@@ -131,31 +123,15 @@ export class SyncManager {
|
|
|
131
123
|
initializeAllEventReceivers();
|
|
132
124
|
// Get all registered event receivers
|
|
133
125
|
const eventReceivers = getAllEventReceivers();
|
|
134
|
-
// Create event handler with connection lifecycle methods
|
|
135
|
-
const eventHandlerWithLifecycle = this.handleEvent.bind(this);
|
|
136
|
-
eventHandlerWithLifecycle.onReconnected = this.handleReconnect.bind(this);
|
|
137
|
-
eventHandlerWithLifecycle.onDisconnected = this.handleDisconnect.bind(this);
|
|
138
126
|
// Register the event handlers with each receiver
|
|
139
127
|
eventReceivers.forEach((receiver, configKey) => {
|
|
140
128
|
if (receiver) {
|
|
141
129
|
// Model events go to handleEvent
|
|
142
|
-
receiver.addModelEventHandler(
|
|
130
|
+
receiver.addModelEventHandler(this.handleEvent.bind(this));
|
|
143
131
|
}
|
|
144
132
|
});
|
|
145
133
|
this.startPeriodicSync();
|
|
146
134
|
}
|
|
147
|
-
handleDisconnect() {
|
|
148
|
-
// Clear local subscription state on disconnect
|
|
149
|
-
this.activeSubscriptions.clear();
|
|
150
|
-
console.log('[SyncManager] Cleared subscriptions on disconnect');
|
|
151
|
-
}
|
|
152
|
-
handleReconnect() {
|
|
153
|
-
// Resubscribe to all active querysets after reconnect
|
|
154
|
-
console.log('[SyncManager] Resubscribing to active querysets after reconnect');
|
|
155
|
-
for (const queryset of this.followedQuerysets) {
|
|
156
|
-
this.subscribeToNamespace(queryset);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
135
|
startPeriodicSync() {
|
|
160
136
|
if (this.periodicSyncTimer)
|
|
161
137
|
return;
|
|
@@ -231,97 +207,6 @@ export class SyncManager {
|
|
|
231
207
|
this.maxWaitTimer = null;
|
|
232
208
|
}
|
|
233
209
|
}
|
|
234
|
-
async subscribeToNamespace(queryset) {
|
|
235
|
-
const key = queryset.semanticKey;
|
|
236
|
-
// Skip if already subscribed
|
|
237
|
-
if (this.activeSubscriptions.has(key))
|
|
238
|
-
return;
|
|
239
|
-
try {
|
|
240
|
-
const config = getConfig();
|
|
241
|
-
const backendConfig = config.backendConfigs[queryset.modelClass.configKey];
|
|
242
|
-
if (!backendConfig) {
|
|
243
|
-
console.error(`[SyncManager] Backend config not found for: ${queryset.modelClass.configKey}`);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
const apiUrl = backendConfig.API_URL;
|
|
247
|
-
// Get Pusher socket ID
|
|
248
|
-
const eventReceiver = getEventReceiver(queryset.modelClass.configKey);
|
|
249
|
-
const socketId = eventReceiver?.pusherClient?.connection?.socket_id;
|
|
250
|
-
if (!socketId) {
|
|
251
|
-
console.warn('[SyncManager] No socket_id available for subscription');
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
// Send subscription request to backend
|
|
255
|
-
const response = await fetch(`${apiUrl}/namespaces/subscribe/`, {
|
|
256
|
-
method: 'POST',
|
|
257
|
-
headers: {
|
|
258
|
-
'Content-Type': 'application/json',
|
|
259
|
-
},
|
|
260
|
-
body: JSON.stringify({
|
|
261
|
-
socket_id: socketId,
|
|
262
|
-
model_name: queryset.modelClass.modelName,
|
|
263
|
-
filter: queryset._filters // Backend extracts namespace from this
|
|
264
|
-
})
|
|
265
|
-
});
|
|
266
|
-
const result = await response.json();
|
|
267
|
-
if (!result.success) {
|
|
268
|
-
throw new Error('Subscription failed');
|
|
269
|
-
}
|
|
270
|
-
// Subscribe to the namespace-specific Pusher channel
|
|
271
|
-
const namespaceChannel = result.channel; // e.g., "django_app.Message:abc123hash"
|
|
272
|
-
eventReceiver?.subscribe(namespaceChannel);
|
|
273
|
-
this.activeSubscriptions.set(key, {
|
|
274
|
-
queryset,
|
|
275
|
-
subscribedAt: Date.now(),
|
|
276
|
-
channel: namespaceChannel,
|
|
277
|
-
namespace_hash: result.namespace_hash
|
|
278
|
-
});
|
|
279
|
-
console.log(`[SyncManager] Subscribed to namespace channel: ${namespaceChannel}`);
|
|
280
|
-
}
|
|
281
|
-
catch (error) {
|
|
282
|
-
console.error('[SyncManager] Namespace subscription failed:', error);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
async unsubscribeFromNamespace(queryset) {
|
|
286
|
-
const key = queryset.semanticKey;
|
|
287
|
-
if (!this.activeSubscriptions.has(key))
|
|
288
|
-
return;
|
|
289
|
-
const subscription = this.activeSubscriptions.get(key);
|
|
290
|
-
try {
|
|
291
|
-
const config = getConfig();
|
|
292
|
-
const backendConfig = config.backendConfigs[queryset.modelClass.configKey];
|
|
293
|
-
if (!backendConfig) {
|
|
294
|
-
console.error(`[SyncManager] Backend config not found for: ${queryset.modelClass.configKey}`);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
const apiUrl = backendConfig.API_URL;
|
|
298
|
-
const eventReceiver = getEventReceiver(queryset.modelClass.configKey);
|
|
299
|
-
const socketId = eventReceiver?.pusherClient?.connection?.socket_id;
|
|
300
|
-
if (!socketId)
|
|
301
|
-
return;
|
|
302
|
-
// Unsubscribe from the Pusher channel
|
|
303
|
-
if (subscription.channel) {
|
|
304
|
-
eventReceiver?.unsubscribe(subscription.channel);
|
|
305
|
-
}
|
|
306
|
-
// Notify backend to remove subscription
|
|
307
|
-
await fetch(`${apiUrl}/namespaces/unsubscribe/`, {
|
|
308
|
-
method: 'POST',
|
|
309
|
-
headers: {
|
|
310
|
-
'Content-Type': 'application/json',
|
|
311
|
-
},
|
|
312
|
-
body: JSON.stringify({
|
|
313
|
-
socket_id: socketId,
|
|
314
|
-
model_name: queryset.modelClass.modelName,
|
|
315
|
-
filter: queryset._filters
|
|
316
|
-
})
|
|
317
|
-
});
|
|
318
|
-
this.activeSubscriptions.delete(key);
|
|
319
|
-
console.log(`[SyncManager] Unsubscribed from namespace channel: ${subscription.channel}`);
|
|
320
|
-
}
|
|
321
|
-
catch (error) {
|
|
322
|
-
console.error('[SyncManager] Namespace unsubscription failed:', error);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
210
|
followModel(registry, modelClass) {
|
|
326
211
|
const models = this.followedModels.get(registry) || new Set();
|
|
327
212
|
this.followedModels.set(registry, models);
|
|
@@ -423,9 +308,7 @@ export class SyncManager {
|
|
|
423
308
|
// Sync all relevant stores for this model
|
|
424
309
|
console.log(`[SyncManager] Syncing ${storesToSync.length} queryset stores for ${representativeEvent.model}`);
|
|
425
310
|
storesToSync.forEach((store) => {
|
|
426
|
-
this.syncQueue.add(() => this.withTimeout(store.sync(
|
|
427
|
-
canonical_id: representativeEvent.canonical_id // Pass canonical_id
|
|
428
|
-
})));
|
|
311
|
+
this.syncQueue.add(() => this.withTimeout(store.sync()));
|
|
429
312
|
});
|
|
430
313
|
}
|
|
431
314
|
processMetrics(event) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statezero/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"test:e2e": "vitest run --config=vitest.sequential.config.ts tests/e2e",
|
|
33
33
|
"generate:test-apps": "ts-node scripts/generate-test-apps.js",
|
|
34
34
|
"test:adaptors": "playwright test tests/adaptors",
|
|
35
|
-
"test:integration": "playwright test --config=playwright.integration.config.ts",
|
|
36
35
|
"test:coverage": "vitest run --coverage",
|
|
37
36
|
"build": "tsc",
|
|
38
37
|
"parse-queries": "node scripts/perfect-query-parser.js",
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Generate a stable hash for a namespace dictionary.
|
|
3
|
-
* Must match the backend implementation in statezero/core/namespace_utils.py
|
|
4
|
-
*
|
|
5
|
-
* @param {Object} namespace - Namespace dictionary (e.g., {room_id: 5})
|
|
6
|
-
* @returns {string} MD5 hash of the namespace
|
|
7
|
-
*/
|
|
8
|
-
export function getNamespaceHash(namespace: Object): string;
|
|
9
|
-
/**
|
|
10
|
-
* Extract namespace fields from a queryset filter.
|
|
11
|
-
* Namespace fields are those that use exact equality (not __gt, __lt, etc.)
|
|
12
|
-
*
|
|
13
|
-
* @param {Object} filters - Queryset filters
|
|
14
|
-
* @returns {Object} Namespace dictionary
|
|
15
|
-
*/
|
|
16
|
-
export function extractNamespaceFromFilter(filters: Object): Object;
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import crypto from 'crypto-js';
|
|
2
|
-
/**
|
|
3
|
-
* Generate a stable hash for a namespace dictionary.
|
|
4
|
-
* Must match the backend implementation in statezero/core/namespace_utils.py
|
|
5
|
-
*
|
|
6
|
-
* @param {Object} namespace - Namespace dictionary (e.g., {room_id: 5})
|
|
7
|
-
* @returns {string} MD5 hash of the namespace
|
|
8
|
-
*/
|
|
9
|
-
export function getNamespaceHash(namespace) {
|
|
10
|
-
// Sort keys to ensure stable hash
|
|
11
|
-
const namespaceJson = JSON.stringify(namespace, Object.keys(namespace).sort());
|
|
12
|
-
return crypto.MD5(namespaceJson).toString();
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Extract namespace fields from a queryset filter.
|
|
16
|
-
* Namespace fields are those that use exact equality (not __gt, __lt, etc.)
|
|
17
|
-
*
|
|
18
|
-
* @param {Object} filters - Queryset filters
|
|
19
|
-
* @returns {Object} Namespace dictionary
|
|
20
|
-
*/
|
|
21
|
-
export function extractNamespaceFromFilter(filters) {
|
|
22
|
-
if (!filters)
|
|
23
|
-
return {};
|
|
24
|
-
const namespace = {};
|
|
25
|
-
for (const [key, value] of Object.entries(filters)) {
|
|
26
|
-
// Only include exact matches (not lookups like __gt, __in, etc.)
|
|
27
|
-
if (!key.includes('__')) {
|
|
28
|
-
namespace[key] = value;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return namespace;
|
|
32
|
-
}
|