@freecorps/ember-offline 0.2.0 → 0.3.0

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/index.d.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * Contract and limits: docs/design/contracts.md, "Offline".
20
20
  */
21
21
  export { createOfflineStorage, OfflineStore, type OfflineOptions } from "./store.ts";
22
+ export { localOptimisticUpdates, type LocalOptimisticOptions } from "./mutations.ts";
22
23
  export { NeedData, recordKey, type BackendReader, type Commit, type ExternalChange, type OfflineBackend, type QueryLogEntry, type QueryRecord, } from "./backend.ts";
23
24
  export { MemoryBackend, memoryStorage } from "./memory.ts";
24
25
  export { coordinateTabs, TabCoordinator, type LockManagerLike, type TabRole, type TabsOptions } from "./tabs.ts";
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * Contract and limits: docs/design/contracts.md, "Offline".
20
20
  */
21
21
  export { createOfflineStorage, OfflineStore } from "./store.js";
22
+ export { localOptimisticUpdates } from "./mutations.js";
22
23
  export { NeedData, recordKey, } from "./backend.js";
23
24
  export { MemoryBackend, memoryStorage } from "./memory.js";
24
25
  export { coordinateTabs, TabCoordinator } from "./tabs.js";
@@ -0,0 +1,15 @@
1
+ import type { OptimisticUpdate } from "@freecorps/ember-client";
2
+ import { type Identity, type LocalSchema } from "@freecorps/ember-server";
3
+ export interface LocalOptimisticOptions {
4
+ schema: LocalSchema;
5
+ /** The app's mutation modules (`{ messages }` or `{ "messages:send": send }`): public `mutation(...)` definitions. */
6
+ mutations: unknown;
7
+ /** The queries whose loaded values are the replica and get optimistic values (public `query(...)` definitions). */
8
+ queries: unknown;
9
+ /** Who the mutation runs as locally (decoded from the client's token; not verified). */
10
+ identity?: () => Identity | null;
11
+ /** Errors from running a mutation or query locally (default: ignored; the server's answer stands). */
12
+ onError?: (fn: string, e: Error) => void;
13
+ }
14
+ /** One optimistic update per registered mutation, keyed by its name (`"messages:send"`), for `EmberClient`. */
15
+ export declare function localOptimisticUpdates(o: LocalOptimisticOptions): Record<string, OptimisticUpdate>;
@@ -0,0 +1,112 @@
1
+ // Optimistic updates without writing one per query (PLAN §10, Brigade RFC 0016 gap G5): the client runs the same
2
+ // `mutation(...)` the server registers over the documents its loaded queries returned, then runs the registered
3
+ // local queries again over the result. Every subscription whose value changes shows it until the server answers.
4
+ //
5
+ // import * as messages from "../ember/messages";
6
+ // const client = new EmberClient({
7
+ // url,
8
+ // optimisticUpdates: localOptimisticUpdates({ schema, mutations: { messages }, queries: { messages } }),
9
+ // });
10
+ //
11
+ // What it relies on, per call:
12
+ // - The replica is the documents in the loaded values of the registered queries (`documentsIn`), so a mutation sees
13
+ // exactly what the screen shows. A mutation that reads a row nobody loaded behaves as if the row did not exist; one
14
+ // that throws (a rule, `OfflineUnsupported`) changes nothing here, and the server's answer stands.
15
+ // - A query only gets an optimistic value when running it over that replica, before the mutation, gives back the
16
+ // value the server confirmed (deep equal). Projections, counts and queries over rows not on screen fail that check
17
+ // and keep their confirmed value, instead of showing something wrong.
18
+ // - Inserted documents get local ids; the server's confirmed value (its own ids) replaces them.
19
+ import { assertLocalMutation, documentsIn, localQueryRegistry, overlayReader, runLocalMutation, runLocalQuery, flattenFunctions, } from "@freecorps/ember-server";
20
+ import { compareValues } from "@freecorps/ember-values";
21
+ const EMPTY = { scan: () => [], get: () => null };
22
+ /**
23
+ * One clock and seed per pending mutation, kept for as long as it is pending. EmberClient re-runs every pending update
24
+ * on each recompute (a new snapshot, another mutation) with the same pending record: keyed by it, a mutation's local
25
+ * ids and `ctx.random`/`ctx.now` stay the same across re-runs (no remounted rows, stacked edits find the row), and two
26
+ * calls never share a seed, even when the app passes the same args object twice (a form, a double submit).
27
+ */
28
+ const runs = new WeakMap();
29
+ function runOf(mutation) {
30
+ const fresh = () => ({ now: Date.now(), seed: Math.floor(Math.random() * 0x100000000) });
31
+ if (!mutation)
32
+ return fresh();
33
+ let r = runs.get(mutation);
34
+ if (!r)
35
+ runs.set(mutation, (r = fresh()));
36
+ return r;
37
+ }
38
+ function sameValue(a, b) {
39
+ try {
40
+ return compareValues(a, b) === 0;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** One optimistic update per registered mutation, keyed by its name (`"messages:send"`), for `EmberClient`. */
47
+ export function localOptimisticUpdates(o) {
48
+ const queries = localQueryRegistry(o.queries);
49
+ const mutations = new Map();
50
+ for (const [name, fn] of flattenFunctions(o.mutations ?? {})) {
51
+ assertLocalMutation(name, fn);
52
+ mutations.set(name, fn);
53
+ }
54
+ const out = {};
55
+ for (const [name, fn] of mutations) {
56
+ out[name] = (store, args, mutation) => runOptimistic(o, queries, name, fn, store, args, mutation);
57
+ }
58
+ return out;
59
+ }
60
+ function runOptimistic(o, queries, name, fn, store, args, mutation) {
61
+ const m = o.schema.manifest;
62
+ // What the screen shows now (earlier pending mutations included: the store returns the optimistic view).
63
+ const loaded = [];
64
+ for (const [qname, qfn] of queries) {
65
+ for (const q of store.getAllQueries(qname)) {
66
+ if (q.value !== undefined)
67
+ loaded.push({ name: qname, fn: qfn, args: (q.args ?? {}), value: q.value });
68
+ }
69
+ }
70
+ if (!loaded.length)
71
+ return;
72
+ const replica = overlayReader(EMPTY, o.schema);
73
+ // Copies: what the mutation reads (and may change in place) is never the snapshot on screen.
74
+ for (const l of loaded)
75
+ for (const { table, doc } of documentsIn(m, l.value))
76
+ replica.put(table, structuredClone(doc));
77
+ const identity = o.identity?.() ?? null;
78
+ const { now, seed } = runOf(mutation);
79
+ // Only queries the replica reproduces get an optimistic value.
80
+ const reproducible = loaded.filter((l) => {
81
+ try {
82
+ return sameValue(runLocalQuery(l.fn, l.args, { schema: o.schema, reader: replica, identity, now }).value, l.value);
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ });
88
+ if (!reproducible.length)
89
+ return;
90
+ const after = overlayReader(replica, o.schema);
91
+ try {
92
+ runLocalMutation(fn, args, { schema: o.schema, reader: after, identity, now, seed });
93
+ }
94
+ catch (e) {
95
+ o.onError?.(name, e);
96
+ return;
97
+ }
98
+ if (!after.changes().length)
99
+ return;
100
+ for (const l of reproducible) {
101
+ let value;
102
+ try {
103
+ value = runLocalQuery(l.fn, l.args, { schema: o.schema, reader: after, identity, now }).value;
104
+ }
105
+ catch (e) {
106
+ o.onError?.(l.name, e);
107
+ continue;
108
+ }
109
+ if (!sameValue(value, l.value))
110
+ store.setQuery(l.name, l.args, value);
111
+ }
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freecorps/ember-offline",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Offline-first storage for Ember clients: persisted query results, replicated rows and reactive local query execution over IndexedDB, SQLite (expo-sqlite, SQLite-Wasm on OPFS) or TinyBase, coordinated across tabs.",
5
5
  "keywords": [
6
6
  "ember",
@@ -73,9 +73,9 @@
73
73
  "access": "public"
74
74
  },
75
75
  "dependencies": {
76
- "@freecorps/ember-client": "0.2.0",
77
- "@freecorps/ember-server": "0.2.0",
78
- "@freecorps/ember-values": "0.2.0"
76
+ "@freecorps/ember-client": "0.3.0",
77
+ "@freecorps/ember-server": "0.3.0",
78
+ "@freecorps/ember-values": "0.3.0"
79
79
  },
80
80
  "peerDependencies": {
81
81
  "expo-sqlite": ">=15",