@estiva-app/platform 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @estiva-app/platform
2
2
 
3
+ ## 0.5.0
4
+
5
+ A copy on the device a reload draws from (PEE-2, Peek opens instantly). Additive.
6
+
7
+ `createEventSnapshot` keeps one person's signed events from one relay —
8
+ never a folded view — and answers the same filters from them on the next
9
+ load, before the relay has. Every relay answer it takes in is also a
10
+ correction, since the relay has no sequence number to replay: what matches a
11
+ filter the relay just answered and is not in that answer is dropped (a
12
+ deletion, a lost Folder); a cut answer judges only the window it covers; a
13
+ `kind:5` drops what its own author signed; a replaceable event keeps its
14
+ newest. A filter it never saw answered is not answered (`null`), and `since`
15
+ and `until` are not part of a filter's key. It keeps the plaintext of a
16
+ person's own blob beside its event, so the same ciphertext is never
17
+ decrypted twice.
18
+
19
+ - `createEventSnapshot({ storage, scope, version, maxEvents?, saveAfterMs?, now?, timers? })`
20
+ → `{ ready, answer(filters), absorb(filters, events), plaintextOf, keepPlaintext, size, flush, close, discard }`.
21
+ An answer absorbed before `ready` is applied over the stored copy; a failed
22
+ save is asked again; `close` writes what changed, `discard` (sign-out) does not.
23
+ A `kind:5` drops `e` targets and `a` coordinates its own author signed.
24
+ A plaintext is kept only beside the person's own replaceable event.
25
+ - `SnapshotStorage` — `load(scope)` → `{ epoch, record }`, `save(scope, change)`
26
+ with `change.epoch`, `clear()`. `clear` counts a sign-out, and a save naming
27
+ an older count writes nothing: a copy opened before a sign-out, in any tab,
28
+ cannot write the person's data back after it.
29
+ A copy written under another `version` is dropped unread; past `maxEvents`
30
+ (10,000) the least recently seen go.
31
+ - `indexedDbSnapshotStorage(indexedDB, name?)` — the browser's storage, the
32
+ factory passed in (the package compiles without `lib: dom`);
33
+ `memorySnapshotStorage()` for tests and browsers without it; `clear()` is
34
+ sign-out.
35
+ - `snapshotScope(relayUrl, pubkey)`, `snapshotFilterKey(filter)`,
36
+ `matchesSnapshotFilter(filter, event)`.
37
+
3
38
  ## 0.4.0
4
39
 
5
40
  One read a tab holds on to, and reads again when it fails (FOL-5). Additive.
package/README.md CHANGED
@@ -140,9 +140,46 @@ await relayBudget.whenClear()
140
140
  Nothing here gates a read. A read somebody asked for by clicking still goes out
141
141
  at once; only the cadence backs off.
142
142
 
143
+ ### A copy on the device, so a reload draws at once
144
+
145
+ `createEventSnapshot` keeps what the relay told one person — signed events,
146
+ never a folded view — and answers the same filters from it on the next load.
147
+ Fold its answer as you fold the relay's, then send the relay the read as
148
+ before and fold again: the relay's answer replaces the copy's.
149
+
150
+ ```ts
151
+ import { createEventSnapshot, indexedDbSnapshotStorage, snapshotScope } from '@estiva-app/platform'
152
+
153
+ const storage = indexedDbSnapshotStorage(indexedDB, 'my-app-copy')
154
+ const copy = createEventSnapshot({
155
+ storage,
156
+ scope: snapshotScope(relayUrl, pubkey),
157
+ version: 1, // bump when what you fold from it changes meaning
158
+ })
159
+
160
+ // In the transport, on every answer the relay gives:
161
+ copy.absorb(filters, events)
162
+
163
+ // Before the relay answers: null means the copy never saw this read answered.
164
+ await copy.ready
165
+ const drawnFirst = copy.answer(filters)
166
+
167
+ // Signing out: every copy goes, and one still open — in this tab or another —
168
+ // can never write the person's data back (the storage counts sign-outs).
169
+ copy.discard()
170
+ await storage.clear()
171
+ ```
172
+
173
+ Every answer is also a correction: an event the copy holds that matches a
174
+ filter the relay just answered, and is not in that answer, is dropped — a
175
+ deletion, or a Folder the person lost. A `kind:5` drops what its author
176
+ signed. `keepPlaintext` keeps the plaintext of the person's own blob beside
177
+ the event, so an unchanged blob needs no decrypt on the next load. Never
178
+ publish over something drawn from the copy: it may be older than the relay.
179
+
143
180
  ## What this deliberately does not know
144
181
 
145
- Where anything is stored. Peek's version took a `LiveProjectionApi`, and
182
+ Where anything is stored, beyond the copy's storage you hand it. Peek's version took a `LiveProjectionApi`, and
146
183
  extracting that as-is would have baked a Convex assumption into a package two
147
184
  apps depend on. This delivers frames and connection state and nothing else, so
148
185
  it bets on neither outcome of "do we need Convex": if Convex stays nothing here
@@ -0,0 +1,179 @@
1
+ /**
2
+ * A copy of what the relay told one person, kept on the device (PEE-2).
3
+ *
4
+ * The copy holds **signed events**, the stores' raw input, never a folded view:
5
+ * a fold can change shape in any release, an event cannot. So an app draws
6
+ * from the copy by running the reads it would have sent, against the copy, and
7
+ * folding the answer exactly as it folds the relay's — and then sends the same
8
+ * reads to the relay and folds again. Nothing here knows which app or store it
9
+ * feeds.
10
+ *
11
+ * ## What the copy may answer
12
+ *
13
+ * A read whose every filter has been answered by the relay before, on this
14
+ * device, for this person. Its key leaves out `since` and `until`, which are
15
+ * usually a clock: "the last 30 days" asked yesterday is the same question.
16
+ * A filter never asked, or a `search`, is not answered — an empty answer from
17
+ * a copy that simply never saw the question would draw "nothing here", which
18
+ * is a claim, where no answer draws today's placeholder.
19
+ *
20
+ * ## Keeping it honest without a sequence number
21
+ *
22
+ * The relay has no change feed to replay, so every answer it gives is also a
23
+ * correction: an event the copy holds that matches a filter the relay just
24
+ * answered, and is not in that answer, is gone (deleted, or in a Folder this
25
+ * person has lost — Buzz answers lost access with an empty list). When the
26
+ * answer was cut by its `limit`, only the window it covers is judged: below
27
+ * its oldest event the relay said nothing. A `kind:5` drops the events it
28
+ * names that its own author signed. A replaceable event keeps only its newest.
29
+ *
30
+ * ## Plaintexts
31
+ *
32
+ * An app's own blob is ciphertext on the wire; decrypting it is a round trip
33
+ * the relay's signer meters. The plaintext is the person's own data, so it is
34
+ * kept beside the event that carried it and handed back for the same
35
+ * ciphertext — which always decrypts to the same text, so it is never stale.
36
+ */
37
+ import type { SignedEvent } from '@estiva-app/protocol';
38
+ /** A Nostr filter as an app sends it: `ids`, `kinds`, `authors`, `#x`, `since`, `until`, `limit`. */
39
+ export type SnapshotFilter = Record<string, unknown>;
40
+ /** One event in storage, with the plaintext of its content when the app kept one. */
41
+ export interface StoredSnapshotEvent {
42
+ event: SignedEvent;
43
+ plaintext?: string;
44
+ /** Epoch ms of the last answer that carried it: the oldest go first past the cap. */
45
+ seenAt: number;
46
+ }
47
+ /** What a scope holds in storage. */
48
+ export interface SnapshotRecord {
49
+ version: number;
50
+ events: StoredSnapshotEvent[];
51
+ /** The keys of every filter the relay has answered (see {@link snapshotFilterKey}). */
52
+ filters: string[];
53
+ }
54
+ /** What changed since the last save. */
55
+ export interface SnapshotChange {
56
+ version: number;
57
+ /** The storage's sign-out count when the copy was read: a save from before a sign-out is refused. */
58
+ epoch: number;
59
+ put: StoredSnapshotEvent[];
60
+ remove: string[];
61
+ filters: string[];
62
+ }
63
+ /** Where a copy lives between page loads. {@link indexedDbSnapshotStorage} is the browser's. */
64
+ export interface SnapshotStorage {
65
+ /** A scope's record, or null; and the storage's sign-out count, which every save names. */
66
+ load(scope: string): Promise<{
67
+ epoch: number;
68
+ record: SnapshotRecord | null;
69
+ }>;
70
+ /** Writes nothing when `change.epoch` is not the current count: somebody signed out since. */
71
+ save(scope: string, change: SnapshotChange): Promise<void>;
72
+ /**
73
+ * Remove every scope and count a sign-out, so a copy still open — in this
74
+ * tab or another — can never write the person's data back afterwards.
75
+ */
76
+ clear(): Promise<void>;
77
+ }
78
+ export interface SnapshotTimers {
79
+ setTimeout(run: () => void, ms: number): unknown;
80
+ clearTimeout(handle: unknown): void;
81
+ }
82
+ export interface EventSnapshotOptions {
83
+ storage: SnapshotStorage;
84
+ /** Whose blobs' plaintexts are kept; defaults to the pubkey in `scope`. */
85
+ owner?: string;
86
+ /** One person on one relay: {@link snapshotScope}. */
87
+ scope: string;
88
+ /** The app's schema version. A copy written under another one is dropped unread. */
89
+ version: number;
90
+ /** Past this many events the least recently seen are dropped. */
91
+ maxEvents?: number;
92
+ /** How long after a change the copy is written. */
93
+ saveAfterMs?: number;
94
+ now?: () => number;
95
+ timers?: SnapshotTimers;
96
+ }
97
+ export interface EventSnapshot {
98
+ /** Settles once the stored copy has been read, or found unusable. Never rejects. */
99
+ ready: Promise<void>;
100
+ /** The events the copy holds for these filters, as the relay would order them; `null` when it cannot answer. */
101
+ answer(filters: SnapshotFilter[]): SignedEvent[] | null;
102
+ /** Take in the relay's answer to these filters: add what it sent, drop what it no longer has. */
103
+ absorb(filters: SnapshotFilter[], events: SignedEvent[]): void;
104
+ /** The kept plaintext of a ciphertext this copy holds, if any. */
105
+ plaintextOf(content: string): string | undefined;
106
+ keepPlaintext(content: string, plaintext: string): void;
107
+ size(): number;
108
+ /** Write what changed now, rather than after `saveAfterMs`. */
109
+ flush(): Promise<void>;
110
+ /** Stop, writing what changed first; nothing more is written after. */
111
+ close(): void;
112
+ /** Stop and write nothing: the copy is about to be cleared (sign-out). */
113
+ discard(): void;
114
+ }
115
+ export declare const SNAPSHOT_MAX_EVENTS = 10000;
116
+ export declare const SNAPSHOT_SAVE_AFTER_MS = 1000;
117
+ /** The scope of one person's copy on one relay. */
118
+ export declare function snapshotScope(relayUrl: string, pubkey: string): string;
119
+ /** A filter's identity as a question, without its clock (`since`, `until`). */
120
+ export declare function snapshotFilterKey(filter: SnapshotFilter): string;
121
+ /** Whether an event matches a filter, `limit` aside — NIP-01's rule. */
122
+ export declare function matchesSnapshotFilter(filter: SnapshotFilter, event: SignedEvent): boolean;
123
+ export declare function createEventSnapshot(options: EventSnapshotOptions): EventSnapshot;
124
+ /**
125
+ * Keeps nothing between page loads: for a test, and for a browser without
126
+ * IndexedDB, where the copy still answers within one load.
127
+ */
128
+ export declare function memorySnapshotStorage(): SnapshotStorage & {
129
+ records: Map<string, SnapshotRecord>;
130
+ };
131
+ interface IdbRequest<T = unknown> {
132
+ result: T;
133
+ error: unknown;
134
+ onsuccess: ((this: unknown, ev: unknown) => unknown) | null;
135
+ onerror: ((this: unknown, ev: unknown) => unknown) | null;
136
+ }
137
+ interface IdbOpenRequest extends IdbRequest<IdbDatabase> {
138
+ onupgradeneeded: ((this: unknown, ev: unknown) => unknown) | null;
139
+ }
140
+ interface IdbIndex {
141
+ getAll(query: unknown): IdbRequest<unknown[]>;
142
+ getAllKeys(query: unknown): IdbRequest<unknown[]>;
143
+ }
144
+ interface IdbObjectStore {
145
+ put(value: unknown): IdbRequest;
146
+ get(key: unknown): IdbRequest<unknown>;
147
+ delete(key: unknown): IdbRequest;
148
+ clear(): IdbRequest;
149
+ index(name: string): IdbIndex;
150
+ createIndex(name: string, keyPath: string): unknown;
151
+ }
152
+ interface IdbTransaction {
153
+ objectStore(name: string): IdbObjectStore;
154
+ oncomplete: ((this: unknown, ev: unknown) => unknown) | null;
155
+ onerror: ((this: unknown, ev: unknown) => unknown) | null;
156
+ onabort: ((this: unknown, ev: unknown) => unknown) | null;
157
+ error: unknown;
158
+ }
159
+ interface IdbDatabase {
160
+ objectStoreNames: {
161
+ contains(name: string): boolean;
162
+ };
163
+ createObjectStore(name: string, options: {
164
+ keyPath: string | string[];
165
+ }): IdbObjectStore;
166
+ transaction(stores: string[], mode: 'readonly' | 'readwrite'): IdbTransaction;
167
+ close(): void;
168
+ }
169
+ /** The part of `window.indexedDB` a snapshot uses. */
170
+ export interface IdbFactoryLike {
171
+ open(name: string, version: number): IdbOpenRequest;
172
+ }
173
+ /**
174
+ * The browser's storage for a copy: one database, `events` keyed by scope and
175
+ * id, and a `meta` row per scope with its version and answered filters.
176
+ */
177
+ export declare function indexedDbSnapshotStorage(indexedDB: IdbFactoryLike, name?: string): SnapshotStorage;
178
+ export {};
179
+ //# sourceMappingURL=event-snapshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-snapshot.d.ts","sourceRoot":"","sources":["../src/event-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAEvD,qGAAqG;AACrG,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAEpD,qFAAqF;AACrF,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,WAAW,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,qFAAqF;IACrF,MAAM,EAAE,MAAM,CAAA;CACf;AAED,qCAAqC;AACrC,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,mBAAmB,EAAE,CAAA;IAC7B,uFAAuF;IACvF,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,wCAAwC;AACxC,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAA;IACf,qGAAqG;IACrG,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,mBAAmB,EAAE,CAAA;IAC1B,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,gGAAgG;AAChG,MAAM,WAAW,eAAe;IAC9B,2FAA2F;IAC3F,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAA;KAAE,CAAC,CAAA;IAC9E,8FAA8F;IAC9F,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1D;;;OAGG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,CAAC,GAAG,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAA;IAChD,YAAY,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAA;CACpC;AAID,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,eAAe,CAAA;IACxB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,OAAO,EAAE,MAAM,CAAA;IACf,iEAAiE;IACjE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,cAAc,CAAA;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,oFAAoF;IACpF,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IACpB,gHAAgH;IAChH,MAAM,CAAC,OAAO,EAAE,cAAc,EAAE,GAAG,WAAW,EAAE,GAAG,IAAI,CAAA;IACvD,iGAAiG;IACjG,MAAM,CAAC,OAAO,EAAE,cAAc,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,IAAI,CAAA;IAC9D,kEAAkE;IAClE,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IAChD,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACvD,IAAI,IAAI,MAAM,CAAA;IACd,+DAA+D;IAC/D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,uEAAuE;IACvE,KAAK,IAAI,IAAI,CAAA;IACb,0EAA0E;IAC1E,OAAO,IAAI,IAAI,CAAA;CAChB;AAED,eAAO,MAAM,mBAAmB,QAAS,CAAA;AACzC,eAAO,MAAM,sBAAsB,OAAQ,CAAA;AAE3C,mDAAmD;AACnD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEtE;AAMD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAKhE;AAED,wEAAwE;AACxE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAuBzF;AAwBD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CA2NhF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,eAAe,GAAG;IAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAA;CAAE,CAsBlG;AAOD,UAAU,UAAU,CAAC,CAAC,GAAG,OAAO;IAC9B,MAAM,EAAE,CAAC,CAAA;IACT,KAAK,EAAE,OAAO,CAAA;IACd,SAAS,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,IAAI,CAAA;IAC3D,OAAO,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,IAAI,CAAA;CAC1D;AACD,UAAU,cAAe,SAAQ,UAAU,CAAC,WAAW,CAAC;IACtD,eAAe,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,IAAI,CAAA;CAClE;AACD,UAAU,QAAQ;IAChB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAAC,OAAO,EAAE,CAAC,CAAA;IAC7C,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAAC,OAAO,EAAE,CAAC,CAAA;CAClD;AACD,UAAU,cAAc;IACtB,GAAG,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAAA;IAC/B,GAAG,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,CAAC,OAAO,CAAC,CAAA;IACtC,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,CAAA;IAChC,KAAK,IAAI,UAAU,CAAA;IACnB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAA;IAC7B,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAA;CACpD;AACD,UAAU,cAAc;IACtB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,CAAA;IACzC,UAAU,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,IAAI,CAAA;IAC5D,OAAO,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,IAAI,CAAA;IACzD,OAAO,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,IAAI,CAAA;IACzD,KAAK,EAAE,OAAO,CAAA;CACf;AACD,UAAU,WAAW;IACnB,gBAAgB,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAA;IACrD,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;KAAE,GAAG,cAAc,CAAA;IACxF,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,UAAU,GAAG,WAAW,GAAG,cAAc,CAAA;IAC7E,KAAK,IAAI,IAAI,CAAA;CACd;AACD,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,cAAc,CAAA;CACpD;AAsBD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,SAAoB,GAAG,eAAe,CA2E7G"}