@owdproject/nuxt-module-atproto-persistence 4.0.0-alpha.3 → 4.0.0-alpha.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/README.md CHANGED
@@ -14,19 +14,42 @@ replaces `@owdproject/nuxt-module-pinia-persistence` — **do not install both**
14
14
  desktop.
15
15
 
16
16
  Boot order is handled without core coupling: the plugin runs in Nuxt's **pre**
17
- phase (registers the adapter only). Remote hydration runs **lazily** on the first
18
- `getItem`, after the `atproto` OAuth plugin has finished init. Core and apps
19
- already await `$persistedState.isReady()` before restoring windows.
17
+ phase (registers the adapter only). Stores hydrate locally first. Remote hydration runs at `app:created`, after
18
+ the `atproto` OAuth plugin has finished initialization. The Nuxt application
19
+ controllers await `$persistedState.isReady()` before restoring local windows.
20
20
 
21
21
  ## Requirements
22
22
 
23
- - `@owdproject/core` ^3.4.0
24
- - `@owdproject/module-atproto` (provides OAuth session and agents)
23
+ - The matching `@owdproject/nuxt` adapter revision with persisted store opt-ins
24
+ and `ApplicationController.restoreApplication()` (release these together).
25
+ - `@owdproject/nuxt-module-atproto` (provides OAuth session and agents)
26
+
27
+ ### OAuth scope
28
+
29
+ The special `atproto` scope only covers identity. Writing the desktop profile to
30
+ the repository requires explicit **`repo:`** permissions *with actions* (the PDS
31
+ checks `action=create`/`action=update` for `putRecord`), otherwise the write is
32
+ rejected (403 / `ScopeMissingError`):
33
+
34
+ ```ts
35
+ // desktop/nuxt.config.ts
36
+ const ACTIONS = 'action=create&action=update&action=delete'
37
+ atproto: {
38
+ oauth: {
39
+ signInOptions: { scope: `atproto repo:org.owdproject.desktop?${ACTIONS} repo:org.owdproject.application.windows?${ACTIONS} repo:org.owdproject.application.meta?${ACTIONS}` },
40
+ clientMetadata: { local: { scope: `atproto repo:org.owdproject.desktop?${ACTIONS} repo:org.owdproject.application.windows?${ACTIONS} repo:org.owdproject.application.meta?${ACTIONS}` } },
41
+ },
42
+ }
43
+ ```
44
+
45
+ You can use `repo:*?action=create&action=update&action=delete` instead to grant
46
+ access to every record type. Changing the scope requires signing in again
47
+ (existing sessions keep their original grant).
25
48
 
26
49
  ## Installation
27
50
 
28
51
  ```bash
29
- pnpm desktop add module-atproto-persistence
52
+ pnpm desktop add nuxt-module-atproto-persistence
30
53
  ```
31
54
 
32
55
  ## Configuration
@@ -35,8 +58,8 @@ pnpm desktop add module-atproto-persistence
35
58
  // desktop/desktop.config.ts
36
59
  export default defineDesktopConfig({
37
60
  modules: [
38
- '@owdproject/module-atproto',
39
- '@owdproject/module-atproto-persistence',
61
+ '@owdproject/nuxt-module-atproto',
62
+ '@owdproject/nuxt-module-atproto-persistence',
40
63
  ],
41
64
  atprotoPersistence: {
42
65
  // Load atprotoDesktop.owner.did remote state on boot (not only /actor/:did)
@@ -47,7 +70,7 @@ export default defineDesktopConfig({
47
70
 
48
71
  ## Actor resolution (remote hydrate)
49
72
 
50
- On first store read, remote state is fetched once per boot when an actor DID is
73
+ After plugin initialization, remote state is fetched when an actor DID is
51
74
  resolved:
52
75
 
53
76
  1. Route `/actor/:did` — view another actor's desktop
@@ -55,16 +78,69 @@ resolved:
55
78
  3. `loadOwnerDesktopOnMounted: true` — `atprotoDesktop.owner.did`
56
79
  4. Otherwise — IndexedDB only
57
80
 
81
+ ## Desktop scoping
82
+
83
+ Persisted state is namespaced by the **desktop distribution id**, declared as
84
+ `id` in `desktop.config.ts`:
85
+
86
+ ```ts
87
+ export default defineDesktopConfig({
88
+ id: 'atproto-os',
89
+ // ...
90
+ })
91
+ ```
92
+
93
+ The id is resolved from `runtimeConfig.public.desktop.id` (falling back to the
94
+ reversibly encoded branded title/affix, then `name`). It prefixes the AT Protocol record rkeys and
95
+ the local IndexedDB keys, so products configured with distinct stable ids do not share a profile.
96
+ Using the same id deliberately shares that namespace for the same account. Records that do not carry the current desktop prefix are ignored.
97
+
58
98
  ## AT Protocol collections
59
99
 
60
100
  | Pinia store id | AT Proto collection | rkey |
61
101
  |---|---|---|
62
- | `desktop` | `org.owdproject.desktop` | `self` |
63
- | `desktop/application/<appId>/windows` | `org.owdproject.application.windows` | `<appId>` |
64
- | `desktop/application/<appId>/meta` | `org.owdproject.application.meta` | `<appId>` |
102
+ | `desktop` | `org.owdproject.desktop` | `<desktopId>` |
103
+ | `desktop/<sub>` | `org.owdproject.desktop` | `<desktopId>:<encoded sub>` |
104
+ | `desktop/application/<appId>/windows` | `org.owdproject.application.windows` | `<desktopId>:<encoded appId>` |
105
+ | `desktop/application/<appId>/meta` | `org.owdproject.application.meta` | `<desktopId>:<encoded appId>` |
65
106
 
66
107
  When logged in, mapped keys are written to the repo on change (`putRecord`).
67
- Unmapped keys stay local-only.
108
+ Unmapped keys stay local-only. Local IndexedDB keys are `<desktopId>/<pinia store id>`.
109
+
110
+ ## Sign-out
111
+
112
+ On sign-out the module forgets the cached remote state map and wipes the local
113
+ IndexedDB keys for the desktop (`atproto:sessionDeleted`, or the
114
+ `authenticated → anonymous` transition). The desktop shell is expected to close
115
+ its windows on the same lifecycle event so the next account never inherits the
116
+ previous one's layout (see the atproto OS host plugin
117
+ `atproto-reset-on-signout.client.ts`).
118
+
119
+ ## Restore and delivery guarantees
120
+
121
+ The adapter registers synchronously before stores are created. Stores first
122
+ hydrate from IndexedDB. Remote hydration runs at `app:created`, after OAuth
123
+ initialization finishes, without treating a slow login as a guest session.
124
+ Session changes and network recovery can retry failed reads. Remote hydration
125
+ keeps changes made locally while a read was in flight, and a repeated session
126
+ hook does not reopen closed windows.
127
+
128
+ Authenticated changes are saved to a desktop/account-scoped IndexedDB outbox.
129
+ Remote writes are serialized per store; failures retry after five seconds or
130
+ on network recovery. An entry is removed only after a successful write of that
131
+ value. Pending entries survive reloads and take precedence over older remote
132
+ records. This is snapshot persistence, not real-time collaboration; Jetstream
133
+ integration remains separate.
134
+
135
+ Explicit desktop ids preserve case and punctuation and must contain 1–64 ASCII
136
+ letters, digits, dots, underscores or hyphens, starting with a letter or digit.
137
+ Use a stable explicit id for each distribution. Hosts without one retain a
138
+ reversibly encoded branding fallback; changing that branding changes the profile
139
+ namespace. Record-key components encode reserved characters reversibly, including
140
+ multi-segment store ids. Reads paginate through all collection records.
141
+
142
+ Run `pnpm run test:persistence` after installing dev dependencies to verify the
143
+ identity mapping, pagination, slow OAuth, remote restore and offline write retry.
68
144
 
69
145
  ## License
70
146
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "desktop-module-atproto-persistence",
3
3
  "configKey": "atprotoPersistence",
4
- "version": "4.0.0-alpha.3",
4
+ "version": "4.0.0-alpha.4",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
@@ -1,100 +1,275 @@
1
1
  import { createPersistedStatePlugin } from "pinia-plugin-persistedstate-2";
2
2
  import { defineNuxtPlugin } from "nuxt/app";
3
- function deepEqual(obj1, obj2) {
4
- if (obj1 === obj2) return true;
5
- if (!obj1 || !obj2 || typeof obj1 !== "object" || typeof obj2 !== "object") return false;
6
- const keys1 = Object.keys(obj1);
7
- const keys2 = Object.keys(obj2);
8
- if (keys1.length !== keys2.length) return false;
9
- for (const key of keys1) {
10
- if (!Object.prototype.hasOwnProperty.call(obj2, key)) return false;
11
- if (!deepEqual(obj1[key], obj2[key])) return false;
12
- }
13
- return true;
14
- }
15
- import { useAtprotoSession, useAtprotoAgent } from "#imports";
16
- import { get, set, del } from "idb-keyval";
3
+ import { nextTick, watch } from "vue";
4
+ import { useAtprotoAgent, useAtprotoSession } from "#imports";
5
+ import { useDesktopApplication } from "@owdproject/nuxt/runtime/composables/useDesktopApplication";
6
+ import { get, set, del, keys } from "idb-keyval";
17
7
  import {
8
+ parseAtprotoStoreKey,
18
9
  putAtprotoApplicationState,
19
- parseAtprotoStoreKey
10
+ resolveDesktopId,
11
+ toLocalStorageKey
20
12
  } from "./utils/utilAtprotoApplicationStates.js";
21
- import { ensureActorStatesLoaded } from "./utils/utilAtprotoPersistenceLoad.js";
13
+ import {
14
+ ensureActorStatesLoaded,
15
+ resolveRestoreActorDid,
16
+ invalidateActorStates
17
+ } from "./utils/utilAtprotoPersistenceLoad.js";
18
+ import { createAtprotoWriteQueue } from "./utils/utilAtprotoWriteQueue.js";
22
19
  function mapStoreKey(key) {
23
- if (key === "desktop") {
24
- return "owd/desktop";
25
- }
26
- if (key.startsWith("desktop/application/")) {
20
+ if (key === "desktop") return "owd/desktop";
21
+ if (key.startsWith("desktop/application/"))
27
22
  return key.replace(/^desktop\/application\//, "owd/application/");
28
- }
29
- if (key.startsWith("desktop/")) {
23
+ if (key.startsWith("desktop/"))
30
24
  return key.replace(/^desktop\//, "owd/desktop/");
31
- }
32
25
  return key;
33
26
  }
27
+ function replaceState(store, value) {
28
+ store.$patch((state) => {
29
+ for (const [key, next] of Object.entries(value)) {
30
+ const current = state[key];
31
+ if (current && next && !Array.isArray(current) && !Array.isArray(next) && typeof current === "object" && typeof next === "object") {
32
+ for (const old of Object.keys(current)) delete current[old];
33
+ Object.assign(current, next);
34
+ } else {
35
+ state[key] = next;
36
+ }
37
+ }
38
+ });
39
+ }
34
40
  export default defineNuxtPlugin({
35
41
  name: "desktop-plugin-atproto-persistence",
36
- dependsOn: ["pinia", "atproto"],
42
+ dependsOn: ["pinia"],
37
43
  enforce: "pre",
38
- async setup(nuxtApp) {
44
+ setup(nuxtApp) {
39
45
  const pinia = nuxtApp.$pinia;
40
- if (!pinia) {
41
- return;
46
+ if (!pinia) return;
47
+ const desktopId = resolveDesktopId(nuxtApp);
48
+ const stores = /* @__PURE__ */ new Map();
49
+ const defaults = /* @__PURE__ */ new Map();
50
+ const localWrites = /* @__PURE__ */ new Map();
51
+ const versions = /* @__PURE__ */ new Map();
52
+ let hydrating = false;
53
+ let armed = false;
54
+ let booted = false;
55
+ let currentDid;
56
+ let scopeWarned = false;
57
+ let resetTask;
58
+ let stopSessionWatch;
59
+ let retry;
60
+ let syncTask;
61
+ let restoredActor;
62
+ const localKey = (key) => toLocalStorageKey(desktopId, key);
63
+ const outboxPrefix = (did) => `${desktopId}/outbox/${did}/`;
64
+ function authenticatedDid() {
65
+ if (!nuxtApp.$atproto) return void 0;
66
+ const { isLogged, session } = nuxtApp.runWithContext(
67
+ () => useAtprotoSession()
68
+ );
69
+ return isLogged.value ? session.value?.sub : void 0;
42
70
  }
43
- await ensureActorStatesLoaded(nuxtApp);
44
- pinia.use(
45
- createPersistedStatePlugin({
46
- persist: false,
47
- storage: {
48
- getItem: async (piniaKey) => {
49
- const remote = await ensureActorStatesLoaded(nuxtApp);
50
- const mappedKey = mapStoreKey(piniaKey);
51
- if (Object.prototype.hasOwnProperty.call(remote, mappedKey)) {
52
- return JSON.stringify(remote[mappedKey]);
53
- }
54
- const value = await get(piniaKey);
55
- return value ?? null;
56
- },
57
- setItem: async (piniaKey, piniaValue) => {
58
- const previousPiniaValue = await get(piniaKey) ?? null;
59
- await set(piniaKey, piniaValue);
60
- const atprotoTargetRecord = parseAtprotoStoreKey(piniaKey);
61
- if (!atprotoTargetRecord) {
62
- return piniaValue;
71
+ function createQueue() {
72
+ return createAtprotoWriteQueue(
73
+ async (key, value) => {
74
+ const did = authenticatedDid();
75
+ if (!did || did !== currentDid)
76
+ throw new Error("AT Protocol session changed before sync");
77
+ const target = parseAtprotoStoreKey(key, desktopId);
78
+ if (!target) return;
79
+ const agent = nuxtApp.runWithContext(
80
+ () => useAtprotoAgent("authenticated")
81
+ );
82
+ await putAtprotoApplicationState(
83
+ agent,
84
+ did,
85
+ target.collection,
86
+ target.rkey,
87
+ JSON.parse(value)
88
+ );
89
+ const outboxKey = `${outboxPrefix(did)}${key}`;
90
+ if (await get(outboxKey) === value) await del(outboxKey);
91
+ },
92
+ {
93
+ onError: (error) => {
94
+ if (!scopeWarned) {
95
+ scopeWarned = true;
96
+ console.warn(
97
+ "[OWD persistence] Remote save failed; queued locally for retry. For ScopeMissingError, request the matching repo: permissions in OAuth metadata and sign in again.",
98
+ error
99
+ );
63
100
  }
64
- const { isLogged } = nuxtApp.runWithContext(() => useAtprotoSession());
65
- if (!isLogged.value) {
66
- return piniaValue;
67
- }
68
- const { collection, rkey } = atprotoTargetRecord;
69
- try {
70
- const currentObj = JSON.parse(piniaValue);
71
- const previousObj = previousPiniaValue ? JSON.parse(previousPiniaValue) : null;
72
- if (previousObj && deepEqual(currentObj, previousObj)) {
73
- return piniaValue;
74
- }
75
- } catch {
76
- if (piniaValue === previousPiniaValue) {
77
- return piniaValue;
78
- }
101
+ }
102
+ }
103
+ );
104
+ }
105
+ let queue = createQueue();
106
+ const adapter = createPersistedStatePlugin({
107
+ persist: false,
108
+ assertStorage: () => {
109
+ },
110
+ storage: {
111
+ getItem: async (key) => await get(localKey(key)) ?? null,
112
+ setItem(key, value) {
113
+ if (resetTask) return resetTask;
114
+ if (!hydrating) versions.set(key, (versions.get(key) ?? 0) + 1);
115
+ const did = armed && !hydrating ? authenticatedDid() : void 0;
116
+ const previous = localWrites.get(key) ?? Promise.resolve();
117
+ const task = previous.catch(() => {
118
+ }).then(async () => {
119
+ await set(localKey(key), value);
120
+ if (did && did === currentDid && parseAtprotoStoreKey(key, desktopId)) {
121
+ await set(`${outboxPrefix(did)}${key}`, value);
122
+ void queue.enqueue(key, value);
79
123
  }
80
- const agent = nuxtApp.runWithContext(() => useAtprotoAgent("authenticated"));
81
- return putAtprotoApplicationState(
82
- agent,
83
- agent.assertDid,
84
- collection,
85
- rkey,
86
- JSON.parse(piniaValue)
87
- ).then(() => {
88
- return piniaValue;
89
- }).catch(() => {
90
- return piniaValue;
91
- });
92
- },
93
- removeItem: async (key) => {
94
- await del(key);
124
+ });
125
+ localWrites.set(key, task);
126
+ return task;
127
+ },
128
+ removeItem: async (key) => {
129
+ await del(localKey(key));
130
+ }
131
+ }
132
+ });
133
+ pinia.use((context) => {
134
+ const initial = JSON.stringify(context.store.$state);
135
+ const result = adapter(context);
136
+ if (context.store.$persistedState) {
137
+ stores.set(context.store.$id, context.store);
138
+ defaults.set(context.store.$id, initial);
139
+ }
140
+ return result;
141
+ });
142
+ async function syncRemote() {
143
+ await Promise.all(
144
+ [...stores.values()].map((store) => store.$persistedState.isReady())
145
+ );
146
+ await Promise.all(localWrites.values());
147
+ const baseline = new Map(versions);
148
+ const actor = await resolveRestoreActorDid(nuxtApp);
149
+ const did = authenticatedDid();
150
+ if (currentDid !== did) {
151
+ queue.stop();
152
+ queue = createQueue();
153
+ }
154
+ currentDid = did;
155
+ if (restoredActor === (actor ?? "guest")) return;
156
+ try {
157
+ const remote = await ensureActorStatesLoaded(nuxtApp, true);
158
+ if (actor !== await resolveRestoreActorDid(nuxtApp) || did !== authenticatedDid())
159
+ return;
160
+ await Promise.all(
161
+ [...stores.values()].map((store) => store.$persistedState.isReady())
162
+ );
163
+ await Promise.all(localWrites.values());
164
+ const applications = nuxtApp.runWithContext(
165
+ () => useDesktopApplication()
166
+ );
167
+ hydrating = true;
168
+ try {
169
+ for (const [key, store] of stores) {
170
+ const pending = did ? await get(`${outboxPrefix(did)}${key}`) : void 0;
171
+ const value = pending ? JSON.parse(pending) : remote[mapStoreKey(key)];
172
+ if (!value || (versions.get(key) ?? 0) !== (baseline.get(key) ?? 0))
173
+ continue;
174
+ const match = /^desktop\/application\/(.+)\/windows$/.exec(key);
175
+ const app = match ? applications.get(match[1]) : void 0;
176
+ app?.closeAllWindows();
177
+ replaceState(store, value);
178
+ if (app) await app.restoreApplication();
95
179
  }
180
+ } finally {
181
+ await nextTick();
182
+ hydrating = false;
183
+ }
184
+ await Promise.all(localWrites.values());
185
+ armed = true;
186
+ restoredActor = actor ?? "guest";
187
+ if (did) {
188
+ for (const key of await keys()) {
189
+ if (typeof key !== "string" || !key.startsWith(outboxPrefix(did)))
190
+ continue;
191
+ const value = await get(key);
192
+ if (value != null)
193
+ void queue.enqueue(key.slice(outboxPrefix(did).length), value);
194
+ }
195
+ }
196
+ } catch {
197
+ armed = true;
198
+ console.warn(
199
+ "[OWD persistence] Remote restore failed; keeping local windows and retrying."
200
+ );
201
+ if (!retry)
202
+ retry = setTimeout(() => {
203
+ retry = void 0;
204
+ void synchronize();
205
+ }, 5e3);
206
+ }
207
+ }
208
+ function synchronize() {
209
+ if (!booted) return Promise.resolve();
210
+ if (!syncTask)
211
+ syncTask = syncRemote().finally(() => {
212
+ syncTask = void 0;
213
+ });
214
+ return syncTask;
215
+ }
216
+ nuxtApp.hook("app:created", async () => {
217
+ booted = true;
218
+ await synchronize();
219
+ });
220
+ const lifecycle = nuxtApp.hook;
221
+ lifecycle("atproto:sessionCreated", () => synchronize());
222
+ lifecycle("atproto:sessionRestored", () => synchronize());
223
+ function resetPersistence() {
224
+ if (resetTask) return resetTask;
225
+ armed = false;
226
+ currentDid = void 0;
227
+ restoredActor = void 0;
228
+ invalidateActorStates(nuxtApp);
229
+ queue.stop();
230
+ queue = createQueue();
231
+ if (retry) clearTimeout(retry);
232
+ retry = void 0;
233
+ const pending = [...localWrites.values()];
234
+ resetTask = (async () => {
235
+ await nextTick();
236
+ for (const [key, store] of stores) {
237
+ const initial = defaults.get(key);
238
+ if (initial) replaceState(store, JSON.parse(initial));
239
+ }
240
+ await nextTick();
241
+ await Promise.allSettled(pending);
242
+ for (const key of await keys()) {
243
+ if (typeof key === "string" && key.startsWith(`${desktopId}/`))
244
+ await del(key);
245
+ }
246
+ })().finally(() => {
247
+ resetTask = void 0;
248
+ });
249
+ return resetTask;
250
+ }
251
+ lifecycle("atproto:sessionDeleted", resetPersistence);
252
+ nuxtApp.hook("app:mounted", () => {
253
+ const atproto = nuxtApp.$atproto;
254
+ if (!atproto) return;
255
+ stopSessionWatch = watch(
256
+ () => atproto.status?.value,
257
+ (next, previous) => {
258
+ if (previous === "authenticated" && next !== "authenticated")
259
+ void resetPersistence();
96
260
  }
97
- })
98
- );
261
+ );
262
+ });
263
+ const online = () => {
264
+ void queue.flush();
265
+ void synchronize();
266
+ };
267
+ window.addEventListener("online", online);
268
+ nuxtApp.vueApp.onUnmount?.(() => {
269
+ queue.stop();
270
+ stopSessionWatch?.();
271
+ if (retry) clearTimeout(retry);
272
+ window.removeEventListener("online", online);
273
+ });
99
274
  }
100
275
  });
@@ -1,24 +1,47 @@
1
+ /** Reversible record-key encoding; AT Protocol permits `~`, but not `/` or `%`. */
2
+ export declare function encodeRecordKeyPart(value: string): string;
1
3
  /** Resolve PDS endpoint via PLC directory (Bluesky); custom PDSs use the same DID document shape. */
2
4
  export declare function resolveActorServiceEndpoint(did: string): Promise<string>;
5
+ /** Slug used inside rkeys (RecordKey forbids `/`, so desktop state is namespaced with this). */
3
6
  /**
4
- * Maps a Pinia store id to an AT Protocol collection and rkey.
7
+ * Identity of the desktop distribution that owns the persisted profile.
8
+ *
9
+ * Declared as `id` in `desktop.config.ts` (`defineDesktopConfig({ id: 'atproto-os' })`)
10
+ * and surfaced on `runtimeConfig.public.desktop`. Falling back to the branded
11
+ * title/affix keeps two different OWD distributions from sharing records when an
12
+ * explicit `id` is missing.
13
+ */
14
+ export declare function resolveDesktopId(nuxtApp: any): string;
15
+ /** IndexedDB key for a Pinia store id, scoped to the current desktop distribution. */
16
+ export declare function toLocalStorageKey(desktopId: string, piniaKey: string): string;
17
+ /**
18
+ * Maps a Pinia store id to an AT Protocol collection and desktop-scoped rkey.
19
+ *
20
+ * The rkey is prefixed with the desktop id so two different OWD distributions
21
+ * (e.g. "Marco's OWD" vs "Alessio's OWD") never read or overwrite each other's
22
+ * profile, even for the same logged-in user.
5
23
  *
6
24
  * Key format → AT Proto mapping:
7
- * - `desktop/application/<appId>/windows` → collection: `org.owdproject.application.windows`, rkey: `<appId>`
8
- * - `desktop/application/<appId>/meta` → collection: `org.owdproject.application.meta`, rkey: `<appId>`
9
- * - `desktop` (or `desktop/<sub>`) → collection: `org.owdproject.desktop`, rkey: `self` (or `<sub>`)
25
+ * - `desktop/application/<appId>/windows` → collection: `org.owdproject.application.windows`, rkey: `<desktopId>:<appId>`
26
+ * - `desktop/application/<appId>/meta` → collection: `org.owdproject.application.meta`, rkey: `<desktopId>:<appId>`
27
+ * - `desktop` → collection: `org.owdproject.desktop`, rkey: `<desktopId>`
28
+ * - `desktop/<sub>` → collection: `org.owdproject.desktop`, rkey: `<desktopId>:<sub>`
10
29
  *
11
30
  * Returns `null` for keys that are not persisted to AT Proto.
12
31
  */
13
- export declare function parseAtprotoStoreKey(key: string): {
32
+ export declare function parseAtprotoStoreKey(key: string, desktopId: string): {
14
33
  collection: string;
15
34
  rkey: string;
16
35
  } | null;
17
- export declare function listAtprotoApplicationStateRecords(agent: any, repo: string, collection: string): any;
36
+ export declare function listAtprotoApplicationStateRecords(agent: any, repo: string, collection: string): Promise<any[]>;
18
37
  export declare function getAtprotoApplicationState(agent: any, repo: string, collection: string, rkey: string): any;
19
38
  export declare function putAtprotoApplicationState(agent: any, repo: string, collection: string, rkey: string, record: any): any;
20
39
  /**
21
40
  * Loads the full desktop state map for a given actor from the AT Protocol
22
41
  * repository. Returns a map keyed by Pinia store id (`desktop/` prefix).
42
+ *
43
+ * Only records belonging to `desktopId` are considered: rkeys must be exactly
44
+ * `<desktopId>` or start with `<desktopId>:`. Legacy/foreign records are ignored,
45
+ * so opening another distribution's OWD never loads an unrelated profile.
23
46
  */
24
- export declare function loadActorDesktopStateMap(nuxtApp: any, actorDid: string): Promise<Record<string, any>>;
47
+ export declare function loadActorDesktopStateMap(nuxtApp: any, actorDid: string, desktopId: string): Promise<Record<string, any>>;
@@ -1,4 +1,14 @@
1
+ import { useRuntimeConfig } from "nuxt/app";
1
2
  import { useAtprotoAgent, useAtprotoSession } from "#imports";
3
+ export function encodeRecordKeyPart(value) {
4
+ return Array.from(new TextEncoder().encode(value), (byte) => {
5
+ const char = String.fromCharCode(byte);
6
+ return /[a-zA-Z0-9._-]/.test(char) ? char : `~${byte.toString(16).padStart(2, "0")}`;
7
+ }).join("");
8
+ }
9
+ function decodeRecordKeyPart(value) {
10
+ return decodeURIComponent(value.replace(/~/g, "%"));
11
+ }
2
12
  export async function resolveActorServiceEndpoint(did) {
3
13
  const response = await fetch(`https://plc.directory/${did}`);
4
14
  if (!response.ok) {
@@ -7,7 +17,24 @@ export async function resolveActorServiceEndpoint(did) {
7
17
  const data = await response.json();
8
18
  return data.service[0].serviceEndpoint;
9
19
  }
10
- export function parseAtprotoStoreKey(key) {
20
+ export function resolveDesktopId(nuxtApp) {
21
+ const runtimeConfig = nuxtApp.runWithContext(() => useRuntimeConfig());
22
+ const desktop = runtimeConfig.public?.desktop ?? {};
23
+ if (typeof desktop.id === "string" && desktop.id.length > 0) {
24
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(desktop.id)) {
25
+ throw new Error(
26
+ "[OWD persistence] Invalid desktop id; use 1\u201364 ASCII letters, digits, dots, underscores or hyphens."
27
+ );
28
+ }
29
+ return desktop.id;
30
+ }
31
+ const branded = [desktop.title, desktop.affix].filter(Boolean).join("-");
32
+ return `legacy-${encodeRecordKeyPart(branded || desktop.name || "owd")}`;
33
+ }
34
+ export function toLocalStorageKey(desktopId, piniaKey) {
35
+ return `${desktopId}/${piniaKey}`;
36
+ }
37
+ export function parseAtprotoStoreKey(key, desktopId) {
11
38
  if (key.startsWith("desktop/application/")) {
12
39
  const parts = key.split("/");
13
40
  const last = parts[parts.length - 1];
@@ -15,26 +42,39 @@ export function parseAtprotoStoreKey(key) {
15
42
  const appId = parts.slice(2, -1).join("/");
16
43
  return {
17
44
  collection: `org.owdproject.application.${last}`,
18
- rkey: appId
45
+ rkey: `${desktopId}:${encodeRecordKeyPart(appId)}`
19
46
  };
20
47
  }
21
48
  }
22
49
  if (key === "desktop" || key.startsWith("desktop/")) {
23
- const sub = key.split("/")[1] ?? "self";
24
50
  if (!key.startsWith("desktop/application/")) {
51
+ const sub = key.slice("desktop/".length);
25
52
  return {
26
53
  collection: "org.owdproject.desktop",
27
- rkey: sub
54
+ rkey: key === "desktop" ? desktopId : `${desktopId}:${encodeRecordKeyPart(sub)}`
28
55
  };
29
56
  }
30
57
  }
31
58
  return null;
32
59
  }
33
- export function listAtprotoApplicationStateRecords(agent, repo, collection) {
34
- return agent.com.atproto.repo.listRecords({
35
- repo,
36
- collection
37
- }).then((response) => response.data).then((data) => data.records).catch(() => []);
60
+ export async function listAtprotoApplicationStateRecords(agent, repo, collection) {
61
+ const records = [];
62
+ let cursor;
63
+ const seen = /* @__PURE__ */ new Set();
64
+ do {
65
+ const { data } = await agent.com.atproto.repo.listRecords({
66
+ repo,
67
+ collection,
68
+ cursor,
69
+ limit: 100
70
+ });
71
+ records.push(...data.records);
72
+ cursor = data.cursor;
73
+ if (cursor && seen.has(cursor))
74
+ throw new Error("AT Protocol returned a repeated records cursor");
75
+ if (cursor) seen.add(cursor);
76
+ } while (cursor);
77
+ return records;
38
78
  }
39
79
  export function getAtprotoApplicationState(agent, repo, collection, rkey) {
40
80
  return agent.com.atproto.repo.getRecord({
@@ -48,19 +88,24 @@ export function putAtprotoApplicationState(agent, repo, collection, rkey, record
48
88
  repo,
49
89
  collection,
50
90
  rkey,
51
- record
91
+ record: { ...record, $type: collection }
52
92
  });
53
93
  }
54
- export async function loadActorDesktopStateMap(nuxtApp, actorDid) {
55
- const { isLogged, session } = nuxtApp.runWithContext(() => useAtprotoSession());
94
+ export async function loadActorDesktopStateMap(nuxtApp, actorDid, desktopId) {
95
+ const { isLogged, session } = nuxtApp.runWithContext(
96
+ () => useAtprotoSession()
97
+ );
56
98
  const isOwner = isLogged.value && session.value?.sub === actorDid;
57
99
  let actorAgent;
58
100
  if (isOwner) {
59
101
  actorAgent = nuxtApp.runWithContext(() => useAtprotoAgent("authenticated"));
60
102
  } else {
61
103
  const actorServiceEndpoint = await resolveActorServiceEndpoint(actorDid);
62
- actorAgent = nuxtApp.runWithContext(() => useAtprotoAgent(actorServiceEndpoint));
104
+ actorAgent = nuxtApp.runWithContext(
105
+ () => useAtprotoAgent(actorServiceEndpoint)
106
+ );
63
107
  }
108
+ const prefix = `${desktopId}:`;
64
109
  const [
65
110
  atprotoActorDesktopList,
66
111
  atprotoApplicationWindowsList,
@@ -82,17 +127,31 @@ export async function loadActorDesktopStateMap(nuxtApp, actorDid) {
82
127
  "org.owdproject.application.meta"
83
128
  )
84
129
  ]);
130
+ for (const result of [
131
+ atprotoActorDesktopList,
132
+ atprotoApplicationWindowsList,
133
+ atprotoApplicationMetaList
134
+ ]) {
135
+ if (result.status === "rejected") throw result.reason;
136
+ }
85
137
  const stateMap = {};
86
138
  if (atprotoActorDesktopList.status === "fulfilled") {
87
139
  for (const record of atprotoActorDesktopList.value) {
88
140
  const rkey = record.uri.split("/").pop();
89
- const piniaKey = rkey === "self" ? "owd/desktop" : `owd/desktop/${rkey}`;
90
- stateMap[piniaKey] = record.value;
141
+ if (rkey === desktopId) {
142
+ const { $type, ...state } = record.value;
143
+ stateMap["owd/desktop"] = state;
144
+ } else if (typeof rkey === "string" && rkey.startsWith(prefix)) {
145
+ const { $type, ...state } = record.value;
146
+ stateMap[`owd/desktop/${decodeRecordKeyPart(rkey.slice(prefix.length))}`] = state;
147
+ }
91
148
  }
92
149
  }
93
150
  if (atprotoApplicationWindowsList.status === "fulfilled") {
94
151
  for (const record of atprotoApplicationWindowsList.value) {
95
- const appId = record.uri.split("/").pop();
152
+ const rkey = record.uri.split("/").pop();
153
+ if (typeof rkey !== "string" || !rkey.startsWith(prefix)) continue;
154
+ const appId = decodeRecordKeyPart(rkey.slice(prefix.length));
96
155
  stateMap[`owd/application/${appId}/windows`] = {
97
156
  windows: record.value.windows
98
157
  };
@@ -100,7 +159,9 @@ export async function loadActorDesktopStateMap(nuxtApp, actorDid) {
100
159
  }
101
160
  if (atprotoApplicationMetaList.status === "fulfilled") {
102
161
  for (const record of atprotoApplicationMetaList.value) {
103
- const appId = record.uri.split("/").pop();
162
+ const rkey = record.uri.split("/").pop();
163
+ if (typeof rkey !== "string" || !rkey.startsWith(prefix)) continue;
164
+ const appId = decodeRecordKeyPart(rkey.slice(prefix.length));
104
165
  stateMap[`owd/application/${appId}/meta`] = {
105
166
  ...record.value
106
167
  };
@@ -1,5 +1,4 @@
1
- /** Wait until the nuxt-atproto plugin has finished OAuth init (or timeout as guest). */
2
- export declare function waitForAtprotoReady(nuxtApp: any): Promise<void>;
3
1
  export declare function resolveRestoreActorDid(nuxtApp: any): Promise<string | undefined>;
4
- /** One remote fetch per boot; subsequent getItem calls reuse the same promise. */
5
- export declare function ensureActorStatesLoaded(nuxtApp: any): Promise<Record<string, unknown>>;
2
+ export declare function ensureActorStatesLoaded(nuxtApp: any, refresh?: boolean): Promise<Record<string, unknown>>;
3
+ /** Invalidate this host's remote profile when its account signs out. */
4
+ export declare function invalidateActorStates(nuxtApp: object): void;
@@ -1,56 +1,44 @@
1
1
  import { useAtprotoSession } from "#imports";
2
2
  import { useRouter, useRuntimeConfig } from "nuxt/app";
3
- import { loadActorDesktopStateMap } from "./utilAtprotoApplicationStates.js";
4
- const ATPROTO_READY_TIMEOUT_MS = 2e3;
5
- const ATPROTO_READY_POLL_MS = 10;
6
- function sleep(ms) {
7
- return new Promise((resolve) => setTimeout(resolve, ms));
8
- }
9
- export async function waitForAtprotoReady(nuxtApp) {
10
- const deadline = Date.now() + ATPROTO_READY_TIMEOUT_MS;
11
- while (Date.now() < deadline) {
12
- const $atproto = nuxtApp.$atproto;
13
- if ($atproto && $atproto.status.value !== "initializing") {
14
- return;
15
- }
16
- await sleep(ATPROTO_READY_POLL_MS);
17
- }
18
- }
3
+ import {
4
+ loadActorDesktopStateMap,
5
+ resolveDesktopId
6
+ } from "./utilAtprotoApplicationStates.js";
19
7
  export async function resolveRestoreActorDid(nuxtApp) {
20
8
  const router = nuxtApp.runWithContext(() => useRouter());
21
9
  await router.isReady();
22
10
  const routeDid = router.currentRoute.value.params.did;
23
- if (typeof routeDid === "string" && routeDid.length > 0) {
24
- return routeDid;
25
- }
26
- const { isLogged, session } = nuxtApp.runWithContext(() => useAtprotoSession());
27
- if (isLogged.value && session.value?.sub) {
28
- return session.value.sub;
29
- }
30
- const runtimeConfig = nuxtApp.runWithContext(() => useRuntimeConfig());
31
- const desktop = runtimeConfig.public.desktop;
32
- if (desktop.atprotoPersistence?.loadOwnerDesktopOnMounted) {
11
+ if (typeof routeDid === "string" && routeDid.length > 0) return routeDid;
12
+ if (!nuxtApp.$atproto) return void 0;
13
+ const { isLogged, session } = nuxtApp.runWithContext(
14
+ () => useAtprotoSession()
15
+ );
16
+ if (isLogged.value && session.value?.sub) return session.value.sub;
17
+ const desktop = nuxtApp.runWithContext(() => useRuntimeConfig()).public.desktop;
18
+ if (desktop.atprotoPersistence?.loadOwnerDesktopOnMounted)
33
19
  return desktop.atprotoDesktop?.owner?.did;
34
- }
35
20
  return void 0;
36
21
  }
37
- let actorStatesPromise = null;
38
- async function loadActorStatesOnce(nuxtApp) {
39
- await waitForAtprotoReady(nuxtApp);
22
+ const loads = /* @__PURE__ */ new WeakMap();
23
+ export async function ensureActorStatesLoaded(nuxtApp, refresh = false) {
40
24
  const actorDid = await resolveRestoreActorDid(nuxtApp);
41
- if (!actorDid) {
42
- return {};
43
- }
44
- try {
45
- const states = await loadActorDesktopStateMap(nuxtApp, actorDid);
46
- return states;
47
- } catch (err) {
48
- return {};
25
+ if (!actorDid) return {};
26
+ const desktopId = resolveDesktopId(nuxtApp);
27
+ let cache = loads.get(nuxtApp);
28
+ if (!cache) loads.set(nuxtApp, cache = /* @__PURE__ */ new Map());
29
+ const key = `${desktopId}/${actorDid}`;
30
+ if (refresh) cache.delete(key);
31
+ if (!cache.has(key)) {
32
+ const task = loadActorDesktopStateMap(nuxtApp, actorDid, desktopId).catch(
33
+ (error) => {
34
+ cache.delete(key);
35
+ throw error;
36
+ }
37
+ );
38
+ cache.set(key, task);
49
39
  }
40
+ return cache.get(key);
50
41
  }
51
- export function ensureActorStatesLoaded(nuxtApp) {
52
- if (!actorStatesPromise) {
53
- actorStatesPromise = loadActorStatesOnce(nuxtApp);
54
- }
55
- return actorStatesPromise;
42
+ export function invalidateActorStates(nuxtApp) {
43
+ loads.delete(nuxtApp);
56
44
  }
@@ -0,0 +1,9 @@
1
+ /** Serializes remote writes per store and retries the latest value after failures. */
2
+ export declare function createAtprotoWriteQueue(write: (key: string, value: string) => Promise<void>, options?: {
3
+ retryMs?: number;
4
+ onError?: (error: unknown) => void;
5
+ }): {
6
+ enqueue(key: string, value: string): Promise<void> | undefined;
7
+ flush: () => Promise<void[]>;
8
+ stop(): void;
9
+ };
@@ -0,0 +1,57 @@
1
+ export function createAtprotoWriteQueue(write, options = {}) {
2
+ const pending = /* @__PURE__ */ new Map();
3
+ const acknowledged = /* @__PURE__ */ new Map();
4
+ const running = /* @__PURE__ */ new Map();
5
+ const timers = /* @__PURE__ */ new Map();
6
+ let stopped = false;
7
+ async function drain(key) {
8
+ while (!stopped && pending.has(key)) {
9
+ const value = pending.get(key);
10
+ try {
11
+ await write(key, value);
12
+ acknowledged.set(key, value);
13
+ if (pending.get(key) === value) pending.delete(key);
14
+ } catch (error) {
15
+ options.onError?.(error);
16
+ if (!stopped && !timers.has(key)) {
17
+ timers.set(
18
+ key,
19
+ setTimeout(() => {
20
+ timers.delete(key);
21
+ void flush(key);
22
+ }, options.retryMs ?? 5e3)
23
+ );
24
+ }
25
+ return;
26
+ }
27
+ }
28
+ }
29
+ function flush(key) {
30
+ if (stopped) return Promise.resolve();
31
+ const active = running.get(key);
32
+ if (active) return active;
33
+ const timer = timers.get(key);
34
+ if (timer) clearTimeout(timer);
35
+ timers.delete(key);
36
+ const task = drain(key).finally(() => {
37
+ running.delete(key);
38
+ });
39
+ running.set(key, task);
40
+ return task;
41
+ }
42
+ return {
43
+ enqueue(key, value) {
44
+ if (stopped) return;
45
+ if (!pending.has(key) && acknowledged.get(key) === value) return;
46
+ pending.set(key, value);
47
+ return flush(key);
48
+ },
49
+ flush: () => Promise.all([...pending.keys()].map(flush)),
50
+ stop() {
51
+ stopped = true;
52
+ for (const timer of timers.values()) clearTimeout(timer);
53
+ timers.clear();
54
+ pending.clear();
55
+ }
56
+ };
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owdproject/nuxt-module-atproto-persistence",
3
- "version": "4.0.0-alpha.3",
3
+ "version": "4.0.0-alpha.4",
4
4
  "description": "Pinia persistence via AT Protocol repository, ESM-friendly",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/atproto-os/module-atproto-persistence",
@@ -37,7 +37,8 @@
37
37
  "dev:build": "nuxt build playground",
38
38
  "dev:generate": "NUXT_APP_BASE_URL=${NUXT_APP_BASE_URL:-/module-atproto-persistence/} nuxt generate playground",
39
39
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && cd playground && pnpm exec nuxt prepare",
40
- "validate": "desktop validate ."
40
+ "validate": "desktop validate .",
41
+ "test:persistence": "vitest run"
41
42
  },
42
43
  "dependencies": {
43
44
  "@nuxt/kit": "^4.6.2",
@@ -51,13 +52,16 @@
51
52
  "@owdproject/core": "^4.0.0-alpha.3",
52
53
  "nuxt": "^4.6.2",
53
54
  "pinia": "^3.0.0",
54
- "typescript": "~6.0.3"
55
+ "typescript": "~6.0.3",
56
+ "vitest": "^4.1.12",
57
+ "vue": "^3.5.44"
55
58
  },
56
59
  "peerDependencies": {
57
60
  "@owdproject/core": "^4.0.0-alpha.3",
58
61
  "@owdproject/nuxt-module-atproto": "^4.0.0-alpha.3",
59
- "pinia": "^3.0.0",
60
- "@owdproject/nuxt": "^4.0.0-alpha.3"
62
+ "pinia": "^3.0.0 || ^4.0.0",
63
+ "@owdproject/nuxt": "^4.0.0-alpha.3",
64
+ "vue": ">=3.3.0"
61
65
  },
62
66
  "publishConfig": {
63
67
  "access": "public",