@procedures/store 0.7.0 → 0.8.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/CHANGELOG.md +77 -0
- package/README.md +98 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/indexeddb/sync-state-store.d.ts +40 -0
- package/dist/indexeddb/sync-state-store.d.ts.map +1 -0
- package/dist/indexeddb/sync-state-store.js +94 -0
- package/dist/indexeddb/sync-state-store.js.map +1 -0
- package/dist/indexeddb.d.ts +7 -2
- package/dist/indexeddb.d.ts.map +1 -1
- package/dist/indexeddb.js +6 -2
- package/dist/indexeddb.js.map +1 -1
- package/dist/storage/api.d.ts +94 -11
- package/dist/storage/api.d.ts.map +1 -1
- package/dist/storage/api.js +177 -24
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/feed.d.ts +133 -0
- package/dist/storage/feed.d.ts.map +1 -0
- package/dist/storage/feed.js +159 -0
- package/dist/storage/feed.js.map +1 -0
- package/dist/storage/file-queue.d.ts.map +1 -1
- package/dist/storage/file-queue.js +2 -1
- package/dist/storage/file-queue.js.map +1 -1
- package/dist/storage/hybrid.d.ts +415 -20
- package/dist/storage/hybrid.d.ts.map +1 -1
- package/dist/storage/hybrid.js +1405 -196
- package/dist/storage/hybrid.js.map +1 -1
- package/dist/storage/index.d.ts +6 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +6 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/sync-state.d.ts +56 -0
- package/dist/storage/sync-state.d.ts.map +1 -0
- package/dist/storage/sync-state.js +58 -0
- package/dist/storage/sync-state.js.map +1 -0
- package/dist/storage/synced-registry.d.ts +22 -4
- package/dist/storage/synced-registry.d.ts.map +1 -1
- package/dist/storage/synced-registry.js +63 -31
- package/dist/storage/synced-registry.js.map +1 -1
- package/dist/storage/types.d.ts +22 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/indexeddb/sync-state-store.ts +101 -0
- package/src/indexeddb.ts +7 -2
- package/src/storage/api.ts +240 -28
- package/src/storage/feed.ts +251 -0
- package/src/storage/file-queue.ts +2 -1
- package/src/storage/hybrid.ts +1526 -203
- package/src/storage/index.ts +17 -0
- package/src/storage/sync-state.ts +89 -0
- package/src/storage/synced-registry.ts +76 -31
- package/src/storage/types.ts +25 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The push feed of the remote changes of a `HybridStorage` (roadmap CLI-21, CLI-15).
|
|
3
|
+
*
|
|
4
|
+
* A `RemoteChangeFeed` pushes the deltas of the remote. When the `since` of a delta is the cursor
|
|
5
|
+
* of the storage, the storage applies the delta without a read. A gap, a lost event or a new
|
|
6
|
+
* subscription makes the storage read the changes since its cursor. Thus the feed replaces most
|
|
7
|
+
* of the periodic pulls, and the pulls stay correct when the feed loses an event.
|
|
8
|
+
*
|
|
9
|
+
* `EventsChangeFeed` is the feed of the change events of the core collection procedures: the topic
|
|
10
|
+
* `collections.<name>` of `events.subscribe` (`documentation/EVENTS.md`). By default, an event has
|
|
11
|
+
* only the ids and the versions: the feed gives it as a hint, and the storage reads the values
|
|
12
|
+
* with a pull, through the checks of a read. The file imports no module of Node, so it runs in a
|
|
13
|
+
* browser.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { StorageChanges } from "@procedures/collections";
|
|
17
|
+
import {
|
|
18
|
+
collectionChangeTopic,
|
|
19
|
+
subscribeRemote,
|
|
20
|
+
type RemoteEvent,
|
|
21
|
+
type SubscribeOptions,
|
|
22
|
+
type Subscription,
|
|
23
|
+
type SubscriptionError,
|
|
24
|
+
} from "@procedures/client";
|
|
25
|
+
import type { EventTransport } from "@procedures/client/events";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A delta of the remote that a feed pushes: the changes between two versions of the remote. The
|
|
29
|
+
* changes have the form of `getChangesSince()`.
|
|
30
|
+
*/
|
|
31
|
+
export interface RemoteChanges<T> extends StorageChanges<T> {
|
|
32
|
+
/**
|
|
33
|
+
* The version of the remote before the changes. A storage applies the delta without a read only
|
|
34
|
+
* when this version is its cursor. Without it, the storage reads the changes.
|
|
35
|
+
*/
|
|
36
|
+
readonly since?: string | undefined;
|
|
37
|
+
/** The origin of the writes of the delta, when one origin made all of them. */
|
|
38
|
+
readonly origin?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A hint of the remote (roadmap CLI-15): the ids that changed between two versions, without their
|
|
43
|
+
* values. A value reaches a subscriber only through a read, with the checks of a read.
|
|
44
|
+
*/
|
|
45
|
+
export interface RemoteChangeHint {
|
|
46
|
+
/** The version of the remote before the changes, or undefined when the chain of versions starts. */
|
|
47
|
+
readonly since?: string | undefined;
|
|
48
|
+
/** The version of the remote after the changes, or undefined for a remote without a change log. */
|
|
49
|
+
readonly version?: string | undefined;
|
|
50
|
+
/** The ids of the items that changed: the listener reads their values. */
|
|
51
|
+
readonly changed: readonly string[];
|
|
52
|
+
/** The ids that were deleted. A delete needs no value. */
|
|
53
|
+
readonly deleted: readonly string[];
|
|
54
|
+
/** The origin of the writes, when one origin made all of them. */
|
|
55
|
+
readonly origin?: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The functions that get the pushes of a feed. */
|
|
59
|
+
export interface RemoteChangeListener<T> {
|
|
60
|
+
/** This function gets a delta of the remote with its values. The listener applies it, or reads the changes. */
|
|
61
|
+
onChanges(changes: RemoteChanges<T>): void;
|
|
62
|
+
/**
|
|
63
|
+
* This function gets a hint without values. The listener reads the changes, or applies the
|
|
64
|
+
* deletes when no value changed. Without it, a hint goes to `onGap()`.
|
|
65
|
+
*/
|
|
66
|
+
onHint?(hint: RemoteChangeHint): void;
|
|
67
|
+
/**
|
|
68
|
+
* This function tells that the feed can have lost changes: a gap of the events, a lost event, or
|
|
69
|
+
* a new subscription. The listener reads the changes since its cursor.
|
|
70
|
+
*/
|
|
71
|
+
onGap(): void;
|
|
72
|
+
/** This function gets the error that stops the feed. The listener then reads the changes only with its pulls. */
|
|
73
|
+
onError?(error: unknown): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A push feed of the changes of a remote storage (roadmap CLI-21). */
|
|
77
|
+
export interface RemoteChangeFeed<T> {
|
|
78
|
+
/**
|
|
79
|
+
* This method starts to push the changes to a listener. Each new subscription calls `onGap()`
|
|
80
|
+
* when it starts, so the listener reads the changes that came before it.
|
|
81
|
+
*
|
|
82
|
+
* @returns The function that stops the pushes. A second use of it does nothing.
|
|
83
|
+
*/
|
|
84
|
+
subscribe(listener: RemoteChangeListener<T>): () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An object that subscribes to the topics of the event bus of a server: a `Client`, or a
|
|
89
|
+
* `WebSocketTransport` of `@procedures/client`.
|
|
90
|
+
*/
|
|
91
|
+
export interface EventSubscriber {
|
|
92
|
+
subscribe<E = unknown>(
|
|
93
|
+
topics: string | readonly string[],
|
|
94
|
+
handler: (data: E, event: RemoteEvent<E>) => void | Promise<void>,
|
|
95
|
+
options?: SubscribeOptions,
|
|
96
|
+
): Subscription;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The source of the events of an `EventsChangeFeed`: an `EventSubscriber`, or a transport. */
|
|
100
|
+
export type ChangeEventSource = EventSubscriber | EventTransport;
|
|
101
|
+
|
|
102
|
+
/** The options of an `EventsChangeFeed`. */
|
|
103
|
+
export interface EventsChangeFeedOptions {
|
|
104
|
+
/** The collection of the core collection procedures. The topic is `collections.<collection>`. */
|
|
105
|
+
collection?: string | undefined;
|
|
106
|
+
/** The topic of the change events, for example with a partition. It comes before `collection`. */
|
|
107
|
+
topic?: string | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* The options of the subscription: the resubscribe rules, the backoff, the clock and the
|
|
110
|
+
* metadata of the call. The feed gives `onSubscribed` and `onError` itself.
|
|
111
|
+
* @default `{ maxAttempts: Infinity }`: the feed resubscribes until it stops
|
|
112
|
+
*/
|
|
113
|
+
subscription?: Omit<SubscribeOptions, "onSubscribed" | "onError"> | undefined;
|
|
114
|
+
/** This function gets the error that ends the subscription, for example `TOPIC_NOT_FOUND`. */
|
|
115
|
+
onError?: ((error: SubscriptionError) => void) | undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True for a source with `subscribe()`: it subscribes itself. */
|
|
119
|
+
function isSubscriber(source: ChangeEventSource): source is EventSubscriber {
|
|
120
|
+
return typeof (source as Partial<EventSubscriber>).subscribe === "function";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True for a string, or for no value. */
|
|
124
|
+
const optionalString = (value: unknown): value is string | undefined => value === undefined || typeof value === "string";
|
|
125
|
+
|
|
126
|
+
/** True for a list of ids. */
|
|
127
|
+
const isIds = (value: unknown): value is string[] => Array.isArray(value) && value.every((id) => typeof id === "string");
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The delta or the hint of a change event of the collection procedures, or undefined for an
|
|
131
|
+
* event of another form. The event comes over the wire: the function examines each field.
|
|
132
|
+
*
|
|
133
|
+
* - An event with `since`, `version` and a value for each changed id is a delta: `changes`.
|
|
134
|
+
* - Each other event is a hint: an event without values, an event with withheld values (the
|
|
135
|
+
* filter of the host refused them), or a hint of a storage without a change log.
|
|
136
|
+
*/
|
|
137
|
+
function eventOf<T>(data: unknown): { readonly changes: RemoteChanges<T> } | { readonly hint: RemoteChangeHint } | undefined {
|
|
138
|
+
if (typeof data !== "object" || data === null) return undefined;
|
|
139
|
+
const event = data as { since?: unknown; version?: unknown; changed?: unknown; deleted?: unknown; values?: unknown; withheld?: unknown; origin?: unknown };
|
|
140
|
+
const { since, version, origin } = event;
|
|
141
|
+
if (!optionalString(since) || !optionalString(version) || !optionalString(origin)) return undefined;
|
|
142
|
+
if (!isIds(event.changed) || !isIds(event.deleted) || (event.withheld !== undefined && !isIds(event.withheld))) return undefined;
|
|
143
|
+
let values: Array<[string, T]> | undefined;
|
|
144
|
+
if (event.values !== undefined) {
|
|
145
|
+
if (!Array.isArray(event.values)) return undefined;
|
|
146
|
+
values = [];
|
|
147
|
+
for (const value of event.values as unknown[]) {
|
|
148
|
+
const entry = value as { id?: unknown; value?: unknown } | null;
|
|
149
|
+
if (typeof entry !== "object" || entry === null || typeof entry.id !== "string" || !("value" in entry)) return undefined;
|
|
150
|
+
values.push([entry.id, entry.value as T]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const withOrigin = origin !== undefined ? { origin } : {};
|
|
154
|
+
const valued = new Set(values?.map(([id]) => id));
|
|
155
|
+
const withheld = (event.withheld as string[] | undefined) ?? [];
|
|
156
|
+
const complete = values !== undefined && withheld.length === 0 && (event.changed as string[]).every((id) => valued.has(id));
|
|
157
|
+
if (since !== undefined && version !== undefined && complete) {
|
|
158
|
+
return { changes: { since, version, full: false, changed: values!, deleted: event.deleted as string[], ...withOrigin } };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
hint: {
|
|
162
|
+
...(since !== undefined && { since }),
|
|
163
|
+
...(version !== undefined && { version }),
|
|
164
|
+
changed: event.changed as string[],
|
|
165
|
+
deleted: event.deleted as string[],
|
|
166
|
+
...withOrigin,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The push feed of the change events of the core collection procedures (roadmap CLI-15). It
|
|
173
|
+
* subscribes to the topic `collections.<name>` with `events.subscribe`. The delivery of the events
|
|
174
|
+
* is at most once, so the feed treats them as hints:
|
|
175
|
+
*
|
|
176
|
+
* - An event without values (the default of the procedures) or with withheld values goes to
|
|
177
|
+
* `onHint()`. The storage reads the values with a pull, so each value passes the checks of a
|
|
178
|
+
* read. A hint with only deletes applies without a pull when `since` is the cursor.
|
|
179
|
+
* - An event with the values of each changed id (the option `values` of the procedures) goes to
|
|
180
|
+
* `onChanges()`. The storage applies it without a read when `since` is its cursor.
|
|
181
|
+
* - A gap of the numbers of the events, a lost event (`dropped`), an event of another form, and
|
|
182
|
+
* each confirmation of the subscription (the first one and each resubscribe) call `onGap()`.
|
|
183
|
+
* - The error that ends the subscription goes to `onError` of the options and of the listener.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```typescript
|
|
187
|
+
* const users = new HybridStorage(new ApiStorage(client, { collection: "users" }), {
|
|
188
|
+
* versionOf: (user) => user.rev,
|
|
189
|
+
* feed: new EventsChangeFeed(client, { collection: "users" }),
|
|
190
|
+
* });
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export class EventsChangeFeed<T> implements RemoteChangeFeed<T> {
|
|
194
|
+
/** The topic of the change events. */
|
|
195
|
+
readonly topic: string;
|
|
196
|
+
private readonly source: ChangeEventSource;
|
|
197
|
+
private readonly subscription: Omit<SubscribeOptions, "onSubscribed" | "onError">;
|
|
198
|
+
private readonly onError: ((error: SubscriptionError) => void) | undefined;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param source - A `Client` or a `WebSocketTransport`, or another transport of the client
|
|
202
|
+
* @param options - The collection or the topic, and the options of the subscription
|
|
203
|
+
*/
|
|
204
|
+
constructor(source: ChangeEventSource, options: EventsChangeFeedOptions) {
|
|
205
|
+
const topic = options.topic ?? (options.collection !== undefined ? collectionChangeTopic(options.collection) : undefined);
|
|
206
|
+
if (topic === undefined || topic === "") throw new TypeError("EventsChangeFeed needs a collection or a topic");
|
|
207
|
+
this.topic = topic;
|
|
208
|
+
this.source = source;
|
|
209
|
+
this.subscription = { maxAttempts: Number.POSITIVE_INFINITY, ...options.subscription };
|
|
210
|
+
this.onError = options.onError;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
subscribe(listener: RemoteChangeListener<T>): () => void {
|
|
214
|
+
/** The number of the next event: a resubscribe starts again at 1 */
|
|
215
|
+
let next = 1;
|
|
216
|
+
let stopped = false;
|
|
217
|
+
const handler = (data: unknown, event: RemoteEvent<unknown>): void => {
|
|
218
|
+
if (stopped) return;
|
|
219
|
+
const lost = event.dropped > 0 || event.seq !== next;
|
|
220
|
+
next = event.seq + 1;
|
|
221
|
+
if (lost) listener.onGap();
|
|
222
|
+
const parsed = eventOf<T>(data);
|
|
223
|
+
if (parsed === undefined) listener.onGap();
|
|
224
|
+
else if ("changes" in parsed) listener.onChanges(parsed.changes);
|
|
225
|
+
// A hint: the listener reads the values with a read of its own
|
|
226
|
+
else if (listener.onHint) listener.onHint(parsed.hint);
|
|
227
|
+
else listener.onGap();
|
|
228
|
+
};
|
|
229
|
+
const options: SubscribeOptions = {
|
|
230
|
+
...this.subscription,
|
|
231
|
+
onSubscribed: () => {
|
|
232
|
+
if (stopped) return;
|
|
233
|
+
next = 1;
|
|
234
|
+
listener.onGap();
|
|
235
|
+
},
|
|
236
|
+
onError: (error) => {
|
|
237
|
+
if (stopped) return;
|
|
238
|
+
this.onError?.(error);
|
|
239
|
+
listener.onError?.(error);
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
const unsubscribe = isSubscriber(this.source)
|
|
243
|
+
? this.source.subscribe<unknown>(this.topic, handler, options)
|
|
244
|
+
: subscribeRemote<unknown>(this.source, this.topic, handler, options);
|
|
245
|
+
return () => {
|
|
246
|
+
if (stopped) return;
|
|
247
|
+
stopped = true;
|
|
248
|
+
unsubscribe();
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -39,7 +39,8 @@ export class FileQueueStore implements QueueStore {
|
|
|
39
39
|
const lines = text.split("\n");
|
|
40
40
|
// The text after the last newline is a part of a line, or empty
|
|
41
41
|
const tail = lines.pop() ?? "";
|
|
42
|
-
|
|
42
|
+
// Each line keeps its number in the file, so an error names the right line after blank lines
|
|
43
|
+
const operations = lines.flatMap((line, index) => (line.trim() === "" ? [] : [this.parse(line, index + 1)]));
|
|
43
44
|
if (tail.trim() !== "") {
|
|
44
45
|
let last: QueuedOperation | undefined;
|
|
45
46
|
try {
|