@owdproject/nuxt-module-atproto-persistence 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Open Web Desktop Team ~ github.com/owdproject
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
20
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img width="160" height="160" src="https://avatars.githubusercontent.com/u/201536780?s=160&v=4" />
3
+ </p>
4
+ <h1 align="center">ATProto Persistence Module</h1>
5
+ <h3 align="center">
6
+ Hybrid Pinia persistence for Open Web Desktop: local-first with AT Protocol sync.
7
+ </h3>
2
8
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
9
+ ## Overview
10
+
11
+ This module registers a Pinia persisted-state adapter backed by **IndexedDB**
12
+ (`idb-keyval`) with optional sync to the user's AT Protocol repository. It
13
+ replaces `@owdproject/nuxt-module-pinia-persistence` — **do not install both** in the same
14
+ desktop.
15
+
16
+ Boot order is handled without core coupling: the plugin runs in Nuxt's **pre**
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
+
21
+ ## Requirements
22
+
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).
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ pnpm desktop add nuxt-module-atproto-persistence
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ ```ts
58
+ // desktop/desktop.config.ts
59
+ export default defineDesktopConfig({
60
+ modules: [
61
+ '@owdproject/nuxt-module-atproto',
62
+ '@owdproject/nuxt-module-atproto-persistence',
63
+ ],
64
+ atprotoPersistence: {
65
+ // Load atprotoDesktop.owner.did remote state on boot (not only /actor/:did)
66
+ loadOwnerDesktopOnMounted: false,
67
+ },
68
+ })
69
+ ```
70
+
71
+ ## Actor resolution (remote hydrate)
72
+
73
+ After plugin initialization, remote state is fetched when an actor DID is
74
+ resolved:
75
+
76
+ 1. Route `/actor/:did` — view another actor's desktop
77
+ 2. Logged-in session — `session.sub`
78
+ 3. `loadOwnerDesktopOnMounted: true` — `atprotoDesktop.owner.did`
79
+ 4. Otherwise — IndexedDB only
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
+
98
+ ## AT Protocol collections
99
+
100
+ | Pinia store id | AT Proto collection | rkey |
101
+ |---|---|---|
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>` |
106
+
107
+ When logged in, mapped keys are written to the repo on change (`putRecord`).
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.
144
+
145
+ ## License
146
+
147
+ This module is released under the [MIT License](LICENSE).
@@ -0,0 +1,5 @@
1
+ import { NuxtModule } from '@nuxt/schema';
2
+
3
+ declare const module$1: NuxtModule<any>;
4
+
5
+ export { module$1 as default };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "desktop-module-atproto-persistence",
3
+ "configKey": "atprotoPersistence",
4
+ "version": "4.0.0-alpha.4",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.3",
7
+ "unbuild": "3.6.1"
8
+ }
9
+ }
@@ -0,0 +1,21 @@
1
+ import { createResolver, addPlugin } from '@nuxt/kit';
2
+ import { defineDesktopModule } from '@owdproject/nuxt/kit';
3
+
4
+ const module$1 = defineDesktopModule({
5
+ meta: {
6
+ name: "desktop-module-atproto-persistence",
7
+ configKey: "atprotoPersistence"
8
+ },
9
+ defaults: {
10
+ loadOwnerDesktopOnMounted: false
11
+ },
12
+ setup(_options, _nuxt) {
13
+ const { resolve } = createResolver(import.meta.url);
14
+ addPlugin({
15
+ src: resolve("./runtime/plugin"),
16
+ mode: "client"
17
+ });
18
+ }
19
+ });
20
+
21
+ export { module$1 as default };
@@ -0,0 +1,2 @@
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,275 @@
1
+ import { createPersistedStatePlugin } from "pinia-plugin-persistedstate-2";
2
+ import { defineNuxtPlugin } from "nuxt/app";
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";
7
+ import {
8
+ parseAtprotoStoreKey,
9
+ putAtprotoApplicationState,
10
+ resolveDesktopId,
11
+ toLocalStorageKey
12
+ } from "./utils/utilAtprotoApplicationStates.js";
13
+ import {
14
+ ensureActorStatesLoaded,
15
+ resolveRestoreActorDid,
16
+ invalidateActorStates
17
+ } from "./utils/utilAtprotoPersistenceLoad.js";
18
+ import { createAtprotoWriteQueue } from "./utils/utilAtprotoWriteQueue.js";
19
+ function mapStoreKey(key) {
20
+ if (key === "desktop") return "owd/desktop";
21
+ if (key.startsWith("desktop/application/"))
22
+ return key.replace(/^desktop\/application\//, "owd/application/");
23
+ if (key.startsWith("desktop/"))
24
+ return key.replace(/^desktop\//, "owd/desktop/");
25
+ return key;
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
+ }
40
+ export default defineNuxtPlugin({
41
+ name: "desktop-plugin-atproto-persistence",
42
+ dependsOn: ["pinia"],
43
+ enforce: "pre",
44
+ setup(nuxtApp) {
45
+ const pinia = nuxtApp.$pinia;
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;
70
+ }
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
+ );
100
+ }
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);
123
+ }
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();
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();
260
+ }
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
+ });
274
+ }
275
+ });
@@ -0,0 +1,47 @@
1
+ /** Reversible record-key encoding; AT Protocol permits `~`, but not `/` or `%`. */
2
+ export declare function encodeRecordKeyPart(value: string): string;
3
+ /** Resolve PDS endpoint via PLC directory (Bluesky); custom PDSs use the same DID document shape. */
4
+ export declare function resolveActorServiceEndpoint(did: string): Promise<string>;
5
+ /** Slug used inside rkeys (RecordKey forbids `/`, so desktop state is namespaced with this). */
6
+ /**
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.
23
+ *
24
+ * Key format → AT Proto mapping:
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>`
29
+ *
30
+ * Returns `null` for keys that are not persisted to AT Proto.
31
+ */
32
+ export declare function parseAtprotoStoreKey(key: string, desktopId: string): {
33
+ collection: string;
34
+ rkey: string;
35
+ } | null;
36
+ export declare function listAtprotoApplicationStateRecords(agent: any, repo: string, collection: string): Promise<any[]>;
37
+ export declare function getAtprotoApplicationState(agent: any, repo: string, collection: string, rkey: string): any;
38
+ export declare function putAtprotoApplicationState(agent: any, repo: string, collection: string, rkey: string, record: any): any;
39
+ /**
40
+ * Loads the full desktop state map for a given actor from the AT Protocol
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.
46
+ */
47
+ export declare function loadActorDesktopStateMap(nuxtApp: any, actorDid: string, desktopId: string): Promise<Record<string, any>>;
@@ -0,0 +1,171 @@
1
+ import { useRuntimeConfig } from "nuxt/app";
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
+ }
12
+ export async function resolveActorServiceEndpoint(did) {
13
+ const response = await fetch(`https://plc.directory/${did}`);
14
+ if (!response.ok) {
15
+ throw new Error("Failed to fetch profile service endpoint");
16
+ }
17
+ const data = await response.json();
18
+ return data.service[0].serviceEndpoint;
19
+ }
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) {
38
+ if (key.startsWith("desktop/application/")) {
39
+ const parts = key.split("/");
40
+ const last = parts[parts.length - 1];
41
+ if (last === "windows" || last === "meta") {
42
+ const appId = parts.slice(2, -1).join("/");
43
+ return {
44
+ collection: `org.owdproject.application.${last}`,
45
+ rkey: `${desktopId}:${encodeRecordKeyPart(appId)}`
46
+ };
47
+ }
48
+ }
49
+ if (key === "desktop" || key.startsWith("desktop/")) {
50
+ if (!key.startsWith("desktop/application/")) {
51
+ const sub = key.slice("desktop/".length);
52
+ return {
53
+ collection: "org.owdproject.desktop",
54
+ rkey: key === "desktop" ? desktopId : `${desktopId}:${encodeRecordKeyPart(sub)}`
55
+ };
56
+ }
57
+ }
58
+ return null;
59
+ }
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;
78
+ }
79
+ export function getAtprotoApplicationState(agent, repo, collection, rkey) {
80
+ return agent.com.atproto.repo.getRecord({
81
+ repo,
82
+ collection,
83
+ rkey
84
+ });
85
+ }
86
+ export function putAtprotoApplicationState(agent, repo, collection, rkey, record) {
87
+ return agent.com.atproto.repo.putRecord({
88
+ repo,
89
+ collection,
90
+ rkey,
91
+ record: { ...record, $type: collection }
92
+ });
93
+ }
94
+ export async function loadActorDesktopStateMap(nuxtApp, actorDid, desktopId) {
95
+ const { isLogged, session } = nuxtApp.runWithContext(
96
+ () => useAtprotoSession()
97
+ );
98
+ const isOwner = isLogged.value && session.value?.sub === actorDid;
99
+ let actorAgent;
100
+ if (isOwner) {
101
+ actorAgent = nuxtApp.runWithContext(() => useAtprotoAgent("authenticated"));
102
+ } else {
103
+ const actorServiceEndpoint = await resolveActorServiceEndpoint(actorDid);
104
+ actorAgent = nuxtApp.runWithContext(
105
+ () => useAtprotoAgent(actorServiceEndpoint)
106
+ );
107
+ }
108
+ const prefix = `${desktopId}:`;
109
+ const [
110
+ atprotoActorDesktopList,
111
+ atprotoApplicationWindowsList,
112
+ atprotoApplicationMetaList
113
+ ] = await Promise.allSettled([
114
+ listAtprotoApplicationStateRecords(
115
+ actorAgent,
116
+ actorDid,
117
+ "org.owdproject.desktop"
118
+ ),
119
+ listAtprotoApplicationStateRecords(
120
+ actorAgent,
121
+ actorDid,
122
+ "org.owdproject.application.windows"
123
+ ),
124
+ listAtprotoApplicationStateRecords(
125
+ actorAgent,
126
+ actorDid,
127
+ "org.owdproject.application.meta"
128
+ )
129
+ ]);
130
+ for (const result of [
131
+ atprotoActorDesktopList,
132
+ atprotoApplicationWindowsList,
133
+ atprotoApplicationMetaList
134
+ ]) {
135
+ if (result.status === "rejected") throw result.reason;
136
+ }
137
+ const stateMap = {};
138
+ if (atprotoActorDesktopList.status === "fulfilled") {
139
+ for (const record of atprotoActorDesktopList.value) {
140
+ const rkey = record.uri.split("/").pop();
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
+ }
148
+ }
149
+ }
150
+ if (atprotoApplicationWindowsList.status === "fulfilled") {
151
+ for (const record of atprotoApplicationWindowsList.value) {
152
+ const rkey = record.uri.split("/").pop();
153
+ if (typeof rkey !== "string" || !rkey.startsWith(prefix)) continue;
154
+ const appId = decodeRecordKeyPart(rkey.slice(prefix.length));
155
+ stateMap[`owd/application/${appId}/windows`] = {
156
+ windows: record.value.windows
157
+ };
158
+ }
159
+ }
160
+ if (atprotoApplicationMetaList.status === "fulfilled") {
161
+ for (const record of atprotoApplicationMetaList.value) {
162
+ const rkey = record.uri.split("/").pop();
163
+ if (typeof rkey !== "string" || !rkey.startsWith(prefix)) continue;
164
+ const appId = decodeRecordKeyPart(rkey.slice(prefix.length));
165
+ stateMap[`owd/application/${appId}/meta`] = {
166
+ ...record.value
167
+ };
168
+ }
169
+ }
170
+ return stateMap;
171
+ }
@@ -0,0 +1,4 @@
1
+ export declare function resolveRestoreActorDid(nuxtApp: any): Promise<string | undefined>;
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;
@@ -0,0 +1,44 @@
1
+ import { useAtprotoSession } from "#imports";
2
+ import { useRouter, useRuntimeConfig } from "nuxt/app";
3
+ import {
4
+ loadActorDesktopStateMap,
5
+ resolveDesktopId
6
+ } from "./utilAtprotoApplicationStates.js";
7
+ export async function resolveRestoreActorDid(nuxtApp) {
8
+ const router = nuxtApp.runWithContext(() => useRouter());
9
+ await router.isReady();
10
+ const routeDid = router.currentRoute.value.params.did;
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)
19
+ return desktop.atprotoDesktop?.owner?.did;
20
+ return void 0;
21
+ }
22
+ const loads = /* @__PURE__ */ new WeakMap();
23
+ export async function ensureActorStatesLoaded(nuxtApp, refresh = false) {
24
+ const actorDid = await resolveRestoreActorDid(nuxtApp);
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);
39
+ }
40
+ return cache.get(key);
41
+ }
42
+ export function invalidateActorStates(nuxtApp) {
43
+ loads.delete(nuxtApp);
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
+ }
@@ -0,0 +1,7 @@
1
+ import type { NuxtModule } from '@nuxt/schema'
2
+
3
+ import type { default as Module } from './module.mjs'
4
+
5
+ export type ModuleOptions = typeof Module extends NuxtModule<infer O> ? Partial<O> : Record<string, any>
6
+
7
+ export { default } from './module.mjs'
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@owdproject/nuxt-module-atproto-persistence",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "4.0.0-alpha.4",
4
+ "description": "Pinia persistence via AT Protocol repository, ESM-friendly",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/atproto-os/module-atproto-persistence",
7
+ "author": {
8
+ "name": "Open Web Desktop Team",
9
+ "url": "https://github.com/owdproject"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/types.d.mts",
15
+ "development": "./src/module.ts",
16
+ "import": "./dist/module.mjs",
17
+ "default": "./dist/module.mjs"
18
+ }
19
+ },
20
+ "main": "./dist/module.mjs",
21
+ "typesVersions": {
22
+ "*": {
23
+ ".": [
24
+ "./dist/types.d.mts"
25
+ ]
26
+ }
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "nx": {
32
+ "name": "module-atproto-persistence"
33
+ },
34
+ "scripts": {
35
+ "prepack": "nuxt-module-build build",
36
+ "dev": "pnpm run dev:prepare && cd playground && pnpm exec nuxt dev",
37
+ "dev:build": "nuxt build playground",
38
+ "dev:generate": "NUXT_APP_BASE_URL=${NUXT_APP_BASE_URL:-/module-atproto-persistence/} nuxt generate playground",
39
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && cd playground && pnpm exec nuxt prepare",
40
+ "validate": "desktop validate .",
41
+ "test:persistence": "vitest run"
42
+ },
43
+ "dependencies": {
44
+ "@nuxt/kit": "^4.6.2",
45
+ "idb-keyval": "^6.2.2",
46
+ "pinia-plugin-persistedstate-2": "^2.0.30",
47
+ "@owdproject/nuxt": "^4.0.0-alpha.3"
48
+ },
49
+ "devDependencies": {
50
+ "@nuxt/module-builder": "^1.0.2",
51
+ "@owdproject/nuxt-app-todo": "^4.0.0-alpha.3",
52
+ "@owdproject/core": "^4.0.0-alpha.3",
53
+ "nuxt": "^4.6.2",
54
+ "pinia": "^3.0.0",
55
+ "typescript": "~6.0.3",
56
+ "vitest": "^4.1.12",
57
+ "vue": "^3.5.44"
58
+ },
59
+ "peerDependencies": {
60
+ "@owdproject/core": "^4.0.0-alpha.3",
61
+ "@owdproject/nuxt-module-atproto": "^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"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public",
68
+ "tag": "alpha",
69
+ "registry": "https://registry.npmjs.org/"
70
+ }
71
+ }