pbtsdb 0.0.1 → 0.1.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.
- package/README.md +546 -574
- package/dist/index.d.ts +376 -403
- package/dist/index.js +280 -477
- package/dist/index.js.map +1 -1
- package/llms.txt +163 -22
- package/package.json +20 -20
package/dist/index.js
CHANGED
|
@@ -1,9 +1,106 @@
|
|
|
1
|
-
import { createCollection } from '@tanstack/db';
|
|
1
|
+
import { createCollection as createCollection$1 } from '@tanstack/react-db';
|
|
2
2
|
import { queryCollectionOptions } from '@tanstack/query-db-collection';
|
|
3
|
+
import { parseWhereExpression, parseOrderByExpression } from '@tanstack/db';
|
|
3
4
|
import { createContext, useContext } from 'react';
|
|
4
5
|
import { jsx } from 'react/jsx-runtime';
|
|
5
6
|
|
|
6
7
|
// src/collection.ts
|
|
8
|
+
function escapeValue(value) {
|
|
9
|
+
if (value === null) {
|
|
10
|
+
return "null";
|
|
11
|
+
}
|
|
12
|
+
if (typeof value === "boolean") {
|
|
13
|
+
return value ? "true" : "false";
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === "number") {
|
|
16
|
+
return value.toString();
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
20
|
+
}
|
|
21
|
+
if (value instanceof Date) {
|
|
22
|
+
return `"${value.toISOString()}"`;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
return `[${value.map(escapeValue).join(",")}]`;
|
|
26
|
+
}
|
|
27
|
+
return `"${String(value)}"`;
|
|
28
|
+
}
|
|
29
|
+
function fieldPathToString(path) {
|
|
30
|
+
return path.join(".");
|
|
31
|
+
}
|
|
32
|
+
function convertToPocketBaseFilter(where) {
|
|
33
|
+
if (!where) {
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
const result = parseWhereExpression(where, {
|
|
37
|
+
handlers: {
|
|
38
|
+
eq: (field, value) => {
|
|
39
|
+
return `${fieldPathToString(field)} = ${escapeValue(value)}`;
|
|
40
|
+
},
|
|
41
|
+
gt: (field, value) => {
|
|
42
|
+
return `${fieldPathToString(field)} > ${escapeValue(value)}`;
|
|
43
|
+
},
|
|
44
|
+
gte: (field, value) => {
|
|
45
|
+
return `${fieldPathToString(field)} >= ${escapeValue(value)}`;
|
|
46
|
+
},
|
|
47
|
+
lt: (field, value) => {
|
|
48
|
+
return `${fieldPathToString(field)} < ${escapeValue(value)}`;
|
|
49
|
+
},
|
|
50
|
+
lte: (field, value) => {
|
|
51
|
+
return `${fieldPathToString(field)} <= ${escapeValue(value)}`;
|
|
52
|
+
},
|
|
53
|
+
and: (...conditions) => {
|
|
54
|
+
if (conditions.length === 0) return "";
|
|
55
|
+
if (conditions.length === 1) return conditions[0];
|
|
56
|
+
return `(${conditions.join(" && ")})`;
|
|
57
|
+
},
|
|
58
|
+
or: (...conditions) => {
|
|
59
|
+
if (conditions.length === 0) return "";
|
|
60
|
+
if (conditions.length === 1) return conditions[0];
|
|
61
|
+
return `(${conditions.join(" || ")})`;
|
|
62
|
+
},
|
|
63
|
+
not: (condition) => {
|
|
64
|
+
return `!(${condition})`;
|
|
65
|
+
},
|
|
66
|
+
in: (field, values) => {
|
|
67
|
+
const valueArray = Array.isArray(values) ? values : [values];
|
|
68
|
+
const uniqueValues = [...new Set(valueArray)];
|
|
69
|
+
const fieldStr = fieldPathToString(field);
|
|
70
|
+
const conditions = uniqueValues.map((v) => `${fieldStr} = ${escapeValue(v)}`);
|
|
71
|
+
return conditions.length > 1 ? `(${conditions.join(" || ")})` : conditions[0];
|
|
72
|
+
},
|
|
73
|
+
like: (field, value) => {
|
|
74
|
+
return `${fieldPathToString(field)} ~ ${escapeValue(value)}`;
|
|
75
|
+
},
|
|
76
|
+
isNull: (field) => {
|
|
77
|
+
return `${fieldPathToString(field)} = null`;
|
|
78
|
+
},
|
|
79
|
+
isUndefined: (field) => {
|
|
80
|
+
return `${fieldPathToString(field)} = null`;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
onUnknownOperator: (operator, args) => {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Unsupported operator '${operator}' for PocketBase filter conversion. Supported operators: eq, gt, gte, lt, lte, in, like, and, or, not, isNull, isUndefined`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return result || void 0;
|
|
90
|
+
}
|
|
91
|
+
function convertToPocketBaseSort(orderBy) {
|
|
92
|
+
if (!orderBy) {
|
|
93
|
+
return void 0;
|
|
94
|
+
}
|
|
95
|
+
const sorts = parseOrderByExpression(orderBy);
|
|
96
|
+
if (sorts.length === 0) {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
return sorts.map((sort) => {
|
|
100
|
+
const field = fieldPathToString(sort.field);
|
|
101
|
+
return sort.direction === "desc" ? `-${field}` : field;
|
|
102
|
+
}).join(",");
|
|
103
|
+
}
|
|
7
104
|
|
|
8
105
|
// src/logger.ts
|
|
9
106
|
var defaultLogger = {
|
|
@@ -12,6 +109,9 @@ var defaultLogger = {
|
|
|
12
109
|
console.log(`[pbtsdb] ${msg}`, context || "");
|
|
13
110
|
}
|
|
14
111
|
},
|
|
112
|
+
info: (msg, context) => {
|
|
113
|
+
console.info(`[pbtsdb] ${msg}`, context || "");
|
|
114
|
+
},
|
|
15
115
|
warn: (msg, context) => {
|
|
16
116
|
console.warn(`[pbtsdb] ${msg}`, context || "");
|
|
17
117
|
},
|
|
@@ -22,6 +122,7 @@ var defaultLogger = {
|
|
|
22
122
|
var currentLogger = defaultLogger;
|
|
23
123
|
var logger = {
|
|
24
124
|
debug: (msg, context) => currentLogger.debug(msg, context),
|
|
125
|
+
info: (msg, context) => currentLogger.info(msg, context),
|
|
25
126
|
warn: (msg, context) => currentLogger.warn(msg, context),
|
|
26
127
|
error: (msg, context) => currentLogger.error(msg, context)
|
|
27
128
|
};
|
|
@@ -32,42 +133,94 @@ function resetLogger() {
|
|
|
32
133
|
currentLogger = defaultLogger;
|
|
33
134
|
}
|
|
34
135
|
|
|
35
|
-
// src/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
136
|
+
// src/collection.ts
|
|
137
|
+
function createCollection(pb, queryClient) {
|
|
138
|
+
return (collectionName, options) => {
|
|
139
|
+
const expandStores = options?.expand;
|
|
140
|
+
const expandString = expandStores ? Object.keys(expandStores).sort().join(",") : void 0;
|
|
141
|
+
async function fetchRecords(loadOptions) {
|
|
142
|
+
const filter = convertToPocketBaseFilter(loadOptions?.where);
|
|
143
|
+
const sort = convertToPocketBaseSort(loadOptions?.orderBy);
|
|
144
|
+
const limit = loadOptions?.limit;
|
|
145
|
+
let items;
|
|
146
|
+
if (!filter && !sort && !limit && !expandString) {
|
|
147
|
+
items = await pb.collection(collectionName).getFullList();
|
|
148
|
+
} else {
|
|
149
|
+
const result = await pb.collection(collectionName).getList(1, limit || 500, {
|
|
150
|
+
filter,
|
|
151
|
+
sort,
|
|
152
|
+
expand: expandString
|
|
153
|
+
});
|
|
154
|
+
items = result.items;
|
|
155
|
+
}
|
|
156
|
+
if (expandStores) {
|
|
157
|
+
for (const record of items) {
|
|
158
|
+
const expandData = record.expand;
|
|
159
|
+
if (!expandData) continue;
|
|
160
|
+
for (const [key, value] of Object.entries(expandData)) {
|
|
161
|
+
const targetStore = expandStores[key];
|
|
162
|
+
if (!targetStore.utils) continue;
|
|
163
|
+
if (!targetStore.isReady()) {
|
|
164
|
+
if (targetStore.config?.syncMode === "on-demand") {
|
|
165
|
+
await targetStore._sync.startSync();
|
|
166
|
+
} else {
|
|
167
|
+
logger.warn(`not syncing ${key} on ${collectionName} because store is not yet ready`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const values = Array.isArray(value) ? value : [value];
|
|
172
|
+
targetStore.utils.writeUpsert(values);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return items;
|
|
177
|
+
}
|
|
178
|
+
const collectionOptions = queryCollectionOptions({
|
|
179
|
+
queryClient,
|
|
180
|
+
queryKey: [collectionName],
|
|
181
|
+
syncMode: options?.syncMode ?? "eager",
|
|
182
|
+
queryFn: async (ctx) => {
|
|
183
|
+
return fetchRecords(ctx.meta?.loadSubsetOptions);
|
|
184
|
+
},
|
|
185
|
+
getKey: (item) => {
|
|
186
|
+
const record = item;
|
|
187
|
+
if (!record || typeof record !== "object" || !("id" in record)) {
|
|
188
|
+
throw new Error(`Record in collection '${collectionName}' is missing required 'id' field. Received: ${JSON.stringify(item)}`);
|
|
189
|
+
}
|
|
190
|
+
return record.id;
|
|
191
|
+
},
|
|
192
|
+
onInsert: options?.onInsert === false ? void 0 : options?.onInsert ?? (async ({ transaction }) => {
|
|
193
|
+
await Promise.all(
|
|
194
|
+
transaction.mutations.map(async (mutation) => {
|
|
195
|
+
const { created, updated, collectionId, collectionName: _, ...data } = mutation.modified;
|
|
196
|
+
await pb.collection(collectionName).create(data);
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
}),
|
|
200
|
+
onUpdate: options?.onUpdate === false ? void 0 : options?.onUpdate ?? (async ({ transaction }) => {
|
|
201
|
+
await Promise.all(
|
|
202
|
+
transaction.mutations.map(async (mutation) => {
|
|
203
|
+
const recordWithId = mutation.original;
|
|
204
|
+
await pb.collection(collectionName).update(recordWithId.id, mutation.changes);
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
}),
|
|
208
|
+
onDelete: options?.onDelete === false ? void 0 : options?.onDelete ?? (async ({ transaction }) => {
|
|
209
|
+
await Promise.all(
|
|
210
|
+
transaction.mutations.map(async (mutation) => {
|
|
211
|
+
const recordWithId = mutation.original;
|
|
212
|
+
await pb.collection(collectionName).delete(recordWithId.id);
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
})
|
|
216
|
+
});
|
|
217
|
+
const collection = createCollection$1(collectionOptions);
|
|
218
|
+
let unsubscribeFn = null;
|
|
219
|
+
let isSubscribed = false;
|
|
220
|
+
let subscriptionPromise = null;
|
|
221
|
+
let subscriptionResolve = null;
|
|
222
|
+
const handleRealtimeEvent = (event) => {
|
|
223
|
+
if (!collection.utils) return;
|
|
71
224
|
collection.utils.writeBatch(() => {
|
|
72
225
|
switch (event.action) {
|
|
73
226
|
case "create":
|
|
@@ -77,469 +230,119 @@ var SubscriptionManager = class {
|
|
|
77
230
|
collection.utils.writeUpdate(event.record);
|
|
78
231
|
break;
|
|
79
232
|
case "delete":
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
collectionName,
|
|
83
|
-
record: event.record
|
|
84
|
-
});
|
|
85
|
-
return;
|
|
233
|
+
if (event.record && "id" in event.record) {
|
|
234
|
+
collection.utils.writeDelete(event.record.id);
|
|
86
235
|
}
|
|
87
|
-
collection.utils.writeDelete(event.record.id);
|
|
88
236
|
break;
|
|
89
237
|
}
|
|
90
238
|
});
|
|
91
239
|
};
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const collectionSubs = this.subscriptions.get(collectionName);
|
|
98
|
-
if (!collectionSubs) return;
|
|
99
|
-
const subscriptionKey = getSubscriptionKey(recordId);
|
|
100
|
-
const state = collectionSubs.get(subscriptionKey);
|
|
101
|
-
if (!state || state.isReconnecting) return;
|
|
102
|
-
state.isReconnecting = true;
|
|
103
|
-
logger.warn("Starting reconnection attempts", { collectionName, subscriptionKey });
|
|
104
|
-
while (state.reconnectAttempts < SUBSCRIPTION_CONFIG.MAX_RECONNECT_ATTEMPTS) {
|
|
105
|
-
const delay = SUBSCRIPTION_CONFIG.BASE_RECONNECT_DELAY_MS * Math.pow(2, state.reconnectAttempts);
|
|
106
|
-
logger.debug(`Reconnection attempt ${state.reconnectAttempts + 1}, waiting ${delay}ms`, {
|
|
107
|
-
collectionName,
|
|
108
|
-
subscriptionKey
|
|
109
|
-
});
|
|
110
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
111
|
-
try {
|
|
112
|
-
const newUnsubscribe = await this.setupSubscription(
|
|
113
|
-
collectionName,
|
|
114
|
-
collection,
|
|
115
|
-
recordId
|
|
116
|
-
);
|
|
117
|
-
state.unsubscribe = newUnsubscribe;
|
|
118
|
-
state.reconnectAttempts = 0;
|
|
119
|
-
state.isReconnecting = false;
|
|
120
|
-
logger.debug("Reconnection successful", { collectionName, subscriptionKey });
|
|
121
|
-
return;
|
|
122
|
-
} catch (error) {
|
|
123
|
-
state.reconnectAttempts++;
|
|
124
|
-
logger.warn(`Reconnection attempt ${state.reconnectAttempts} failed`, {
|
|
125
|
-
collectionName,
|
|
126
|
-
subscriptionKey,
|
|
127
|
-
error
|
|
240
|
+
const startSubscription = async () => {
|
|
241
|
+
if (isSubscribed) return;
|
|
242
|
+
if (!subscriptionPromise) {
|
|
243
|
+
subscriptionPromise = new Promise((resolve) => {
|
|
244
|
+
subscriptionResolve = resolve;
|
|
128
245
|
});
|
|
129
246
|
}
|
|
130
|
-
}
|
|
131
|
-
logger.error("Max reconnection attempts reached", {
|
|
132
|
-
collectionName,
|
|
133
|
-
subscriptionKey,
|
|
134
|
-
attempts: state.reconnectAttempts
|
|
135
|
-
});
|
|
136
|
-
state.isReconnecting = false;
|
|
137
|
-
collectionSubs.delete(subscriptionKey);
|
|
138
|
-
}
|
|
139
|
-
// ============================================================================
|
|
140
|
-
// Core Subscription Methods
|
|
141
|
-
// ============================================================================
|
|
142
|
-
/**
|
|
143
|
-
* Subscribe to real-time updates for a collection.
|
|
144
|
-
* Returns a promise that resolves when the subscription is fully established.
|
|
145
|
-
*
|
|
146
|
-
* @param collectionName - The PocketBase collection name
|
|
147
|
-
* @param collection - The TanStack DB collection to sync with
|
|
148
|
-
* @param recordId - Optional: Subscribe to specific record, or omit for collection-wide updates
|
|
149
|
-
*/
|
|
150
|
-
async subscribe(collectionName, collection, recordId) {
|
|
151
|
-
if (!this.subscriptions.has(collectionName)) {
|
|
152
|
-
this.subscriptions.set(collectionName, /* @__PURE__ */ new Map());
|
|
153
|
-
}
|
|
154
|
-
if (!this.subscriptionPromises.has(collectionName)) {
|
|
155
|
-
this.subscriptionPromises.set(collectionName, /* @__PURE__ */ new Map());
|
|
156
|
-
}
|
|
157
|
-
const collectionSubs = this.subscriptions.get(collectionName);
|
|
158
|
-
const collectionPromises = this.subscriptionPromises.get(collectionName);
|
|
159
|
-
const subscriptionKey = getSubscriptionKey(recordId);
|
|
160
|
-
if (collectionSubs.has(subscriptionKey)) {
|
|
161
|
-
logger.debug("Already subscribed, skipping", { collectionName, subscriptionKey });
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const existingPromise = collectionPromises.get(subscriptionKey);
|
|
165
|
-
if (existingPromise) {
|
|
166
|
-
logger.debug("Pending subscription found, waiting", { collectionName, subscriptionKey });
|
|
167
|
-
return existingPromise;
|
|
168
|
-
}
|
|
169
|
-
collectionSubs.set(subscriptionKey, createPendingSubscriptionState(recordId));
|
|
170
|
-
const subscriptionPromise = (async () => {
|
|
171
247
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
248
|
+
unsubscribeFn = await pb.collection(collectionName).subscribe("*", handleRealtimeEvent);
|
|
249
|
+
isSubscribed = true;
|
|
250
|
+
logger.debug("Subscription started", { collectionName });
|
|
251
|
+
if (subscriptionResolve) {
|
|
252
|
+
subscriptionResolve();
|
|
176
253
|
}
|
|
177
254
|
} catch (error) {
|
|
178
|
-
|
|
179
|
-
collectionPromises.delete(subscriptionKey);
|
|
180
|
-
logger.error("Subscription failed", { collectionName, subscriptionKey, error });
|
|
181
|
-
await this.handleReconnection(collectionName, collection, recordId);
|
|
182
|
-
throw error;
|
|
183
|
-
}
|
|
184
|
-
})();
|
|
185
|
-
collectionPromises.set(subscriptionKey, subscriptionPromise);
|
|
186
|
-
try {
|
|
187
|
-
await subscriptionPromise;
|
|
188
|
-
} catch (_error) {
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Unsubscribe from real-time updates.
|
|
193
|
-
*
|
|
194
|
-
* @param collectionName - The PocketBase collection name
|
|
195
|
-
* @param recordId - Optional: Unsubscribe from specific record, or omit for collection-wide
|
|
196
|
-
*/
|
|
197
|
-
unsubscribe(collectionName, recordId) {
|
|
198
|
-
const collectionSubs = this.subscriptions.get(collectionName);
|
|
199
|
-
if (!collectionSubs) return;
|
|
200
|
-
const subscriptionKey = getSubscriptionKey(recordId);
|
|
201
|
-
const state = collectionSubs.get(subscriptionKey);
|
|
202
|
-
if (state) {
|
|
203
|
-
const unsubPromise = state.unsubscribe();
|
|
204
|
-
if (unsubPromise && typeof unsubPromise.catch === "function") {
|
|
205
|
-
unsubPromise.catch((error) => {
|
|
206
|
-
logger.debug("Unsubscribe failed (expected if connection closed)", {
|
|
207
|
-
collectionName,
|
|
208
|
-
subscriptionKey,
|
|
209
|
-
error
|
|
210
|
-
});
|
|
211
|
-
});
|
|
255
|
+
logger.error("Failed to start subscription", { collectionName, error });
|
|
212
256
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
257
|
+
};
|
|
258
|
+
const stopSubscription = async () => {
|
|
259
|
+
if (!isSubscribed || !unsubscribeFn) return;
|
|
260
|
+
try {
|
|
261
|
+
await unsubscribeFn();
|
|
262
|
+
unsubscribeFn = null;
|
|
263
|
+
isSubscribed = false;
|
|
264
|
+
subscriptionPromise = null;
|
|
265
|
+
subscriptionResolve = null;
|
|
266
|
+
logger.debug("Subscription stopped", { collectionName });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logger.debug("Unsubscribe failed (expected if connection closed)", { collectionName, error });
|
|
221
269
|
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const unsubPromise = state.unsubscribe();
|
|
238
|
-
if (unsubPromise && typeof unsubPromise.catch === "function") {
|
|
239
|
-
unsubPromise.catch((error) => {
|
|
240
|
-
logger.debug("Unsubscribe failed (expected if connection closed)", {
|
|
241
|
-
collectionName,
|
|
242
|
-
error
|
|
243
|
-
});
|
|
270
|
+
};
|
|
271
|
+
const waitForSubscription = async (timeout = 5e3) => {
|
|
272
|
+
if (isSubscribed) return;
|
|
273
|
+
if (!subscriptionPromise) {
|
|
274
|
+
await new Promise((resolve) => {
|
|
275
|
+
const checkInterval = setInterval(() => {
|
|
276
|
+
if (subscriptionPromise) {
|
|
277
|
+
clearInterval(checkInterval);
|
|
278
|
+
resolve();
|
|
279
|
+
}
|
|
280
|
+
}, 10);
|
|
281
|
+
setTimeout(() => {
|
|
282
|
+
clearInterval(checkInterval);
|
|
283
|
+
resolve();
|
|
284
|
+
}, timeout);
|
|
244
285
|
});
|
|
245
286
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Check if subscribed to a collection.
|
|
255
|
-
*
|
|
256
|
-
* @param collectionName - The PocketBase collection name
|
|
257
|
-
* @param recordId - Optional: Check specific record subscription, or omit for collection-wide
|
|
258
|
-
*/
|
|
259
|
-
isSubscribed(collectionName, recordId) {
|
|
260
|
-
const collectionSubs = this.subscriptions.get(collectionName);
|
|
261
|
-
if (!collectionSubs) return false;
|
|
262
|
-
const subscriptionKey = getSubscriptionKey(recordId);
|
|
263
|
-
return collectionSubs.has(subscriptionKey);
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Wait for a subscription to be fully established (useful for testing).
|
|
267
|
-
*
|
|
268
|
-
* @param collectionName - The collection name
|
|
269
|
-
* @param recordId - Optional specific record ID
|
|
270
|
-
* @param timeoutMs - Timeout in milliseconds
|
|
271
|
-
*/
|
|
272
|
-
async waitForSubscription(collectionName, recordId, timeoutMs = SUBSCRIPTION_CONFIG.DEFAULT_WAIT_TIMEOUT_MS) {
|
|
273
|
-
const subscriptionKey = getSubscriptionKey(recordId);
|
|
274
|
-
const collectionSubs = this.subscriptions.get(collectionName);
|
|
275
|
-
if (collectionSubs?.has(subscriptionKey)) {
|
|
276
|
-
const collectionPromises2 = this.subscriptionPromises.get(collectionName);
|
|
277
|
-
if (!collectionPromises2?.has(subscriptionKey)) {
|
|
278
|
-
return;
|
|
287
|
+
if (subscriptionPromise) {
|
|
288
|
+
await Promise.race([
|
|
289
|
+
subscriptionPromise,
|
|
290
|
+
new Promise(
|
|
291
|
+
(_, reject) => setTimeout(() => reject(new Error("Subscription timeout")), timeout)
|
|
292
|
+
)
|
|
293
|
+
]);
|
|
279
294
|
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
295
|
+
};
|
|
296
|
+
collection.on("subscribers:change", (event) => {
|
|
297
|
+
const newCount = event.subscriberCount;
|
|
298
|
+
const previousCount = event.previousSubscriberCount;
|
|
299
|
+
if (newCount > 0 && previousCount === 0) {
|
|
300
|
+
startSubscription().catch(() => {
|
|
301
|
+
});
|
|
302
|
+
} else if (newCount === 0 && previousCount > 0) {
|
|
303
|
+
stopSubscription().catch(() => {
|
|
304
|
+
});
|
|
287
305
|
}
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
291
|
-
setTimeout(() => reject(new Error(`Subscription timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
292
306
|
});
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
* Automatically subscribes when first subscriber is added.
|
|
301
|
-
*
|
|
302
|
-
* @param collectionName - The PocketBase collection name
|
|
303
|
-
* @param collection - The TanStack DB collection to sync with
|
|
304
|
-
*/
|
|
305
|
-
async addSubscriber(collectionName, collection) {
|
|
306
|
-
const currentCount = this.subscriberCounts.get(collectionName) || 0;
|
|
307
|
-
const newCount = currentCount + 1;
|
|
308
|
-
this.subscriberCounts.set(collectionName, newCount);
|
|
309
|
-
logger.debug("Subscriber added", { collectionName, count: newCount });
|
|
310
|
-
const cleanupTimer = this.cleanupTimers.get(collectionName);
|
|
311
|
-
if (cleanupTimer) {
|
|
312
|
-
clearTimeout(cleanupTimer);
|
|
313
|
-
this.cleanupTimers.delete(collectionName);
|
|
314
|
-
logger.debug("Cleanup timer cancelled", { collectionName });
|
|
315
|
-
}
|
|
316
|
-
if (newCount === 1 && !this.isSubscribed(collectionName)) {
|
|
317
|
-
logger.debug("First subscriber - starting subscription", { collectionName });
|
|
318
|
-
await this.subscribe(collectionName, collection);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Track subscriber removal for a collection.
|
|
323
|
-
* Automatically unsubscribes (with delay) when last subscriber is removed.
|
|
324
|
-
*
|
|
325
|
-
* @param collectionName - The PocketBase collection name
|
|
326
|
-
*/
|
|
327
|
-
removeSubscriber(collectionName) {
|
|
328
|
-
const currentCount = this.subscriberCounts.get(collectionName) || 0;
|
|
329
|
-
const newCount = Math.max(0, currentCount - 1);
|
|
330
|
-
this.subscriberCounts.set(collectionName, newCount);
|
|
331
|
-
logger.debug("Subscriber removed", { collectionName, count: newCount });
|
|
332
|
-
if (newCount === 0) {
|
|
333
|
-
const existingTimer = this.cleanupTimers.get(collectionName);
|
|
334
|
-
if (existingTimer) {
|
|
335
|
-
clearTimeout(existingTimer);
|
|
336
|
-
}
|
|
337
|
-
const cleanupTimer = setTimeout(() => {
|
|
338
|
-
const finalCount = this.subscriberCounts.get(collectionName) || 0;
|
|
339
|
-
if (finalCount === 0) {
|
|
340
|
-
logger.debug("Cleanup timer fired - unsubscribing", { collectionName });
|
|
341
|
-
this.unsubscribeAll(collectionName);
|
|
342
|
-
this.subscriberCounts.delete(collectionName);
|
|
343
|
-
}
|
|
344
|
-
this.cleanupTimers.delete(collectionName);
|
|
345
|
-
}, SUBSCRIPTION_CONFIG.CLEANUP_DELAY_MS);
|
|
346
|
-
this.cleanupTimers.set(collectionName, cleanupTimer);
|
|
347
|
-
logger.debug("Cleanup timer scheduled", {
|
|
348
|
-
collectionName,
|
|
349
|
-
delayMs: SUBSCRIPTION_CONFIG.CLEANUP_DELAY_MS
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* Get the current subscriber count for a collection.
|
|
355
|
-
* Useful for debugging and testing.
|
|
356
|
-
*
|
|
357
|
-
* @param collectionName - The PocketBase collection name
|
|
358
|
-
* @returns Current subscriber count
|
|
359
|
-
*/
|
|
360
|
-
getSubscriberCount(collectionName) {
|
|
361
|
-
return this.subscriberCounts.get(collectionName) || 0;
|
|
362
|
-
}
|
|
363
|
-
};
|
|
364
|
-
var CollectionsContext = createContext(null);
|
|
365
|
-
function CollectionsProvider({ collections, children }) {
|
|
366
|
-
return /* @__PURE__ */ jsx(CollectionsContext.Provider, { value: collections, children });
|
|
367
|
-
}
|
|
368
|
-
function useStore(key) {
|
|
369
|
-
const context = useContext(CollectionsContext);
|
|
370
|
-
if (!context) {
|
|
371
|
-
throw new Error("useStore must be used within a CollectionsProvider");
|
|
372
|
-
}
|
|
373
|
-
if (!(key in context)) {
|
|
374
|
-
throw new Error(`Collection "${key}" not found in CollectionsProvider`);
|
|
375
|
-
}
|
|
376
|
-
return context[key];
|
|
307
|
+
Object.assign(collection, {
|
|
308
|
+
collectionName,
|
|
309
|
+
waitForSubscription,
|
|
310
|
+
isSubscribed: () => isSubscribed
|
|
311
|
+
});
|
|
312
|
+
return collection;
|
|
313
|
+
};
|
|
377
314
|
}
|
|
378
|
-
function
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
throw new Error(`Collection "${key}" not found in CollectionsProvider`);
|
|
315
|
+
function createReactProvider(collections) {
|
|
316
|
+
const Context = createContext(null);
|
|
317
|
+
const Provider = ({ children }) => /* @__PURE__ */ jsx(Context.Provider, { value: collections, children });
|
|
318
|
+
function useStore(...keys) {
|
|
319
|
+
const context = useContext(Context);
|
|
320
|
+
if (!context) {
|
|
321
|
+
throw new Error("useStore must be used within the Provider returned by createReactProvider");
|
|
386
322
|
}
|
|
387
|
-
return
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// src/collection.ts
|
|
393
|
-
var CollectionFactory = class {
|
|
394
|
-
constructor(pocketbase, queryClient) {
|
|
395
|
-
this.pocketbase = pocketbase;
|
|
396
|
-
this.queryClient = queryClient;
|
|
397
|
-
this.subscriptionManager = new SubscriptionManager(pocketbase);
|
|
398
|
-
}
|
|
399
|
-
/**
|
|
400
|
-
* Setup automatic subscription lifecycle management.
|
|
401
|
-
* Hooks into TanStack DB's subscriber events to manage real-time subscriptions.
|
|
402
|
-
*/
|
|
403
|
-
setupSubscriptionLifecycle(collectionName, baseCollection) {
|
|
404
|
-
baseCollection.on("subscribers:change", (event) => {
|
|
405
|
-
const newCount = event.subscriberCount;
|
|
406
|
-
const previousCount = event.previousSubscriberCount;
|
|
407
|
-
if (newCount > previousCount) {
|
|
408
|
-
this.subscriptionManager.addSubscriber(collectionName, baseCollection).catch(() => {
|
|
409
|
-
});
|
|
410
|
-
} else if (newCount < previousCount) {
|
|
411
|
-
this.subscriptionManager.removeSubscriber(collectionName);
|
|
323
|
+
return keys.map((key) => {
|
|
324
|
+
if (!(key in context)) {
|
|
325
|
+
throw new Error(`Collection "${String(key)}" not found in collections`);
|
|
412
326
|
}
|
|
327
|
+
return context[key];
|
|
413
328
|
});
|
|
414
329
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
* ```ts
|
|
428
|
-
* const jobsCollection = factory.create('jobs');
|
|
429
|
-
*
|
|
430
|
-
* // In your component - subscription starts automatically
|
|
431
|
-
* const { data } = useLiveQuery((q) =>
|
|
432
|
-
* q.from({ jobs: jobsCollection })
|
|
433
|
-
* );
|
|
434
|
-
* // Subscription stops automatically when component unmounts
|
|
435
|
-
* ```
|
|
436
|
-
*
|
|
437
|
-
* @example
|
|
438
|
-
* With query operators (filters, sorting):
|
|
439
|
-
* ```ts
|
|
440
|
-
* const jobsCollection = factory.create('jobs');
|
|
441
|
-
*
|
|
442
|
-
* // In your component:
|
|
443
|
-
* const { data } = useLiveQuery((q) =>
|
|
444
|
-
* q.from({ jobs: jobsCollection })
|
|
445
|
-
* .where(({ jobs }) => and(
|
|
446
|
-
* eq(jobs.status, 'ACTIVE'),
|
|
447
|
-
* gt(jobs.created, new Date('2025-01-01'))
|
|
448
|
-
* ))
|
|
449
|
-
* .orderBy(({ jobs }) => jobs.created, 'desc')
|
|
450
|
-
* );
|
|
451
|
-
* ```
|
|
452
|
-
*
|
|
453
|
-
* @example
|
|
454
|
-
* With relation expansion:
|
|
455
|
-
* ```ts
|
|
456
|
-
* const jobsCollection = factory.create('jobs', {
|
|
457
|
-
* expand: 'customer,location'
|
|
458
|
-
* });
|
|
459
|
-
*
|
|
460
|
-
* // Expanded relations available in record.expand
|
|
461
|
-
* ```
|
|
462
|
-
*
|
|
463
|
-
* @example
|
|
464
|
-
* With relations (for manual joins):
|
|
465
|
-
* ```ts
|
|
466
|
-
* const customersCollection = factory.create('customers');
|
|
467
|
-
* const jobsCollection = factory.create('jobs', {
|
|
468
|
-
* relations: { customer: customersCollection }
|
|
469
|
-
* });
|
|
470
|
-
*
|
|
471
|
-
* // In your component, manually build joins:
|
|
472
|
-
* const { data } = useLiveQuery((q) =>
|
|
473
|
-
* q.from({ job: jobsCollection })
|
|
474
|
-
* .join(
|
|
475
|
-
* { customer: customersCollection },
|
|
476
|
-
* ({ job, customer }) => eq(job.customer, customer.id),
|
|
477
|
-
* "left"
|
|
478
|
-
* )
|
|
479
|
-
* .select(({ job, customer }) => ({
|
|
480
|
-
* ...job,
|
|
481
|
-
* expand: {
|
|
482
|
-
* customer: customer ? { ...customer } : undefined
|
|
483
|
-
* }
|
|
484
|
-
* }))
|
|
485
|
-
* );
|
|
486
|
-
* ```
|
|
487
|
-
*
|
|
488
|
-
* @example
|
|
489
|
-
* Manual subscription control (advanced):
|
|
490
|
-
* ```ts
|
|
491
|
-
* const jobsCollection = factory.create('jobs');
|
|
492
|
-
*
|
|
493
|
-
* // Manually subscribe to specific record (bypasses automatic lifecycle)
|
|
494
|
-
* await jobsCollection.subscribe('record_id_123');
|
|
495
|
-
*
|
|
496
|
-
* // Check subscription status
|
|
497
|
-
* const isSubbed = jobsCollection.isSubscribed('record_id_123');
|
|
498
|
-
*
|
|
499
|
-
* // Manually unsubscribe
|
|
500
|
-
* jobsCollection.unsubscribe('record_id_123');
|
|
501
|
-
* ```
|
|
502
|
-
*/
|
|
503
|
-
create(collection, options) {
|
|
504
|
-
const baseCollection = createCollection(
|
|
505
|
-
queryCollectionOptions({
|
|
506
|
-
queryKey: [collection],
|
|
507
|
-
queryFn: async () => {
|
|
508
|
-
const queryOptions = {};
|
|
509
|
-
if (options?.expand) {
|
|
510
|
-
queryOptions.expand = options.expand;
|
|
511
|
-
}
|
|
512
|
-
const result = await this.pocketbase.collection(collection).getFullList(queryOptions);
|
|
513
|
-
return result;
|
|
514
|
-
},
|
|
515
|
-
queryClient: this.queryClient,
|
|
516
|
-
getKey: (item) => item.id,
|
|
517
|
-
startSync: options?.startSync ?? false
|
|
518
|
-
})
|
|
519
|
-
);
|
|
520
|
-
const subscribableCollection = Object.assign(baseCollection, {
|
|
521
|
-
subscribe: async (recordId) => {
|
|
522
|
-
await this.subscriptionManager.subscribe(collection, baseCollection, recordId);
|
|
523
|
-
},
|
|
524
|
-
unsubscribe: (recordId) => {
|
|
525
|
-
this.subscriptionManager.unsubscribe(collection, recordId);
|
|
526
|
-
},
|
|
527
|
-
unsubscribeAll: () => {
|
|
528
|
-
this.subscriptionManager.unsubscribeAll(collection);
|
|
529
|
-
},
|
|
530
|
-
isSubscribed: (recordId) => {
|
|
531
|
-
return this.subscriptionManager.isSubscribed(collection, recordId);
|
|
532
|
-
},
|
|
533
|
-
waitForSubscription: async (recordId, timeoutMs) => {
|
|
534
|
-
await this.subscriptionManager.waitForSubscription(collection, recordId, timeoutMs);
|
|
535
|
-
},
|
|
536
|
-
relations: options?.relations || {}
|
|
537
|
-
});
|
|
538
|
-
this.setupSubscriptionLifecycle(collection, baseCollection);
|
|
539
|
-
return subscribableCollection;
|
|
330
|
+
return {
|
|
331
|
+
Provider,
|
|
332
|
+
useStore
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/util.ts
|
|
337
|
+
function newRecordId() {
|
|
338
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
339
|
+
let result = "";
|
|
340
|
+
for (let i = 0; i < 15; i++) {
|
|
341
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
540
342
|
}
|
|
541
|
-
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
542
345
|
|
|
543
|
-
export {
|
|
346
|
+
export { createCollection, createReactProvider, newRecordId, resetLogger, setLogger };
|
|
544
347
|
//# sourceMappingURL=index.js.map
|
|
545
348
|
//# sourceMappingURL=index.js.map
|