@acornly/adapter-firebase 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Free Solo
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
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # @acornly/adapter-firebase
2
2
 
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.
3
+ A `StorageAdapter` that keeps Acornly notes in a site's own Firestore, with no Acornly Cloud involved. The site pays its own Firebase bill and Acornly charges nothing.
4
+
5
+ The adapter reads and writes as whoever is signed in on the site's Firebase Auth, or as a visitor when nobody is. The Firestore security rules this package ships decide what each of them may see and change.
6
+
7
+ ## Set up Firebase
8
+
9
+ 1. Deploy the rules and indexes that ship in the package, [`firestore.rules`](firestore.rules) and [`firestore.indexes.json`](firestore.indexes.json) (also under `node_modules/@acornly/adapter-firebase/`). If the site has no rules of its own, point `firebase.json` at them and run `firebase deploy --only firestore`:
10
+
11
+ ```json
12
+ {
13
+ "firestore": {
14
+ "rules": "node_modules/@acornly/adapter-firebase/firestore.rules",
15
+ "indexes": "node_modules/@acornly/adapter-firebase/firestore.indexes.json"
16
+ }
17
+ }
18
+ ```
19
+
20
+ If it has, copy the functions and the `acornly_` matches into its rules file, and the indexes into its indexes file. Everything they cover is in collections prefixed with `acornly_`.
21
+
22
+ 2. List the scopes the site accepts notes in, as documents in `acornly_scopes` whose id is the scope. Signed-in users can write only to these, plus their own `user:<uid>` scope. Clients cannot write this collection, so add them in the Firebase console or with the Admin SDK:
23
+
24
+ ```ts
25
+ await admin.firestore().doc("acornly_scopes/app:my-site").set({});
26
+ // Let visitors who are not signed in read the public notes in this scope:
27
+ await admin.firestore().doc("acornly_scopes/web:example.com").set({ anonymousRead: true });
28
+ ```
29
+
30
+ 3. Turn on a sign-in method in Firebase Authentication. Notes are written as the signed-in Firebase user, and anonymous sign-ins cannot write.
31
+
32
+ ## Use it
33
+
34
+ ```ts
35
+ import { initializeApp } from "firebase/app";
36
+ import { getAuth } from "firebase/auth";
37
+ import { getFirestore } from "firebase/firestore";
38
+ import { FirebaseAdapter } from "@acornly/adapter-firebase";
39
+
40
+ const app = initializeApp(firebaseConfig);
41
+ const adapter = new FirebaseAdapter({ firestore: getFirestore(app), auth: getAuth(app) });
42
+ ```
43
+
44
+ `firebase` 12 or later in 12.x is a peer dependency: the adapter uses the site's app and adds nothing to the bundle for it.
45
+
46
+ ## Access rules
47
+
48
+ | Who | Can read | Can write |
49
+ | ------------------------------ | --------------------------------------------- | --------------------------------------------------------------------------- |
50
+ | The note's creator | Their own notes, always | Edit or remove their own notes, in listed scopes or their own `user:` scope |
51
+ | Anyone signed in | `public` notes | New notes in listed scopes and their own `user:` scope |
52
+ | A visitor who is not signed in | `public` notes in scopes with `anonymousRead` | Nothing |
53
+ | The site's server (Admin SDK) | Everything (security rules do not apply) | Anything, so it must keep the fields below consistent itself |
54
+
55
+ - Firebase's anonymous sign-ins count as visitors who are not signed in, although they have a user id.
56
+ - Two audiences are supported: `private` (only the creator) and `public`. Circles, user lists and links need Acornly Cloud and are refused.
57
+ - Each note is one document in `acornly_annotations`, named by the note's id. Ids are 1 to 128 letters, digits, `_`, `~` or `-` (Acornly's are ULIDs), so a reply's target IRI holds its parent's id unescaped. Notes imported from W3C files keep their ids, so an import whose ids are IRIs (such as Hypothesis URLs) is refused here; give such notes new ids first.
58
+ - The document holds the annotation as JSON (`json`, up to 64 KB) and, beside it, the fields the rules check: the author's `uid`, `creatorId`, `scope`, `source` (`target.source`, or for a note on a PDF the file's key in `ext.documentKey`, which the JSON's `target.source` then leaves as the URL the note was made on), `parent`, `threadRoot`, `depth`, `parentRev`, `audience`, `modified`, `rev` and `deleted`. When the adapter reads a note, those fields take precedence over the JSON's copy of them, so nobody can make a note look like it is somewhere or for someone it is not. The rest of the annotation is stored as sent, so validate notes from other users before trusting their shape. A document whose JSON is not an object with `target` and `ext` is skipped.
59
+ - Scopes are `app:`, `web:` or `user:` followed by anything but `/`. `modified` is an ISO 8601 time with a `Z` or an offset.
60
+ - `uid` is the signed-in user who created the note, checked by the rules. Show that as the author: the annotation's own `creator` field is whatever the client sent.
61
+ - `rev` is checked by the rules: a note starts at 1 and each update adds one. Every write is a Firestore transaction that reads the note first, so of two concurrent edits one wins and the other gets a `ConflictError`.
62
+ - Context on context (replies, tags, reactions, highlights on a note) targets its parent: `target.source` is `urn:contexthive:annotation:<parent id>`, and it carries `ext.threadRoot` and `ext.depth`. Add it with `adapter.threads.addChild` (or core's `addContext`). The rules check it against the stored parent: the parent must exist, be readable by the replier and not be deleted, and the child must have its scope, thread root, the next depth, and a parent rev the parent has had. The rules cannot see quotes inside the JSON, so only the adapter checks that `ext.parentRev` is set exactly when the child quotes its parent. A child can narrow its parent's audience (a private reply under a public note) but never widen it, on create or on edit. Children nest to any depth.
63
+ - Each note keeps the ids of its direct children in `kids`, changed only in the same write that adds or removes that child, which the rules check. That is how a delete knows a note still has children, even ones the deleter cannot read. It also means anyone who can read a note can read the ids of all its direct children, private ones included, and ULIDs carry their creation time: a private reply's existence and time are not secret, only its content and author.
64
+ - Plain updates keep the tree: no note moves to another scope, document or thread (a note on a PDF keeps its `ext.documentKey`, whatever the update says), `ext.depth`, `ext.parentRev` and the target stay, `ext.deleted` changes only through a delete, a deleted placeholder cannot be edited, and a note with replies keeps its `creator.id`. Plain removes refuse replies and notes with replies; the rules refuse removing a note that has children from any client.
65
+ - Delete with `adapter.threads.deleteNode` (or core's `deleteContext`). Only the author can delete a note (and anyone signed in can remove a placeholder with no children left). One with any reply, even one the author cannot see, becomes a placeholder: no body, author name or edited mark, `ext.deleted: true`. One without replies is removed, and so is each placeholder above it left with none, whoever wrote it: the rules let anyone remove a placeholder whose last child goes in the same write. A long chain of placeholders goes six at a time, each step its own transaction, since Firestore allows the rules of one write 20 document lookups; should a later step fail, the placeholders it leaves have no children and readers never see them.
66
+ - **Replies under a narrowed note stay readable.** Firestore rules check each note on its own and cannot follow a reply's parents, so when a note is narrowed from public to private, other people's public replies below it stay readable to anyone signed in who asks for them, by id or by loading the thread. Core's `assembleThread`, which the SDK and the built-in UI use, hides them; the private note itself stays private. Delete or narrow such replies when that is not enough.
67
+ - Edit history lives in `acornly_versions` (`adapter.versions`), one document per note holding its saved versions. Only the note's author can save, read or clear them; removing a note or turning it into a placeholder drops them. A Firestore document holds 1 MiB, so when a note's versions outgrow that, the oldest are dropped.
68
+ - Deleting a Firebase Auth user leaves their notes. Remove them from the site's server with the Admin SDK, which the rules do not check: delete a user's notes without replies, and turn ones with replies into placeholders, taking each removed note out of its parent's `kids`.
69
+ - `modified` comes from the client and is what `list({ since })` compares, so a client with a wrong clock (or one that writes a time far ahead) can make its edit invisible, or always present, to other clients' delta syncs. Treat delta sync as a shortcut and resync with a full `list` now and then.
70
+ - Ids are chosen by the client, so creating an id that already exists fails even when the existing note is hidden. Use random ids (Acornly's are ULIDs) so this reveals nothing. Reading an id behaves the same way: Firestore refuses a hidden note but reports a missing one as missing, so a probe can tell that a hidden id exists.
71
+ - Edits need the scope to still be listed (or be the author's own `user:` scope), as on Supabase; deletes do not, so a delisted scope's notes can still be removed by their authors.
72
+ - A parent's `kids` lives in its document, which Firestore caps at 1 MiB: a note can take tens of thousands of direct children, not more.
73
+ - Each reply, tag or reaction also writes its parent's document (its `kids`). Firestore sustains about one write a second to one document and a transaction gives up after five retries, so a burst of reactions on one note can fail some of them.
74
+ - Writes need a connection: Firestore transactions fail offline, whatever the site's cache settings.
75
+
76
+ ## What it costs
77
+
78
+ Firestore bills each document a query returns or a rule looks up, not each request, so a page's notes cost reads in proportion to how many there are:
79
+
80
+ - `list` runs two queries for someone signed in (public notes, and their own) and one for a visitor, and is billed one read per note returned, at least one per query. A delta sync (`since`) reads only the notes changed since.
81
+ - Loading a thread reads its root and every node in it. Counting replies (`threads.countChildren`) reads each parent and runs two count queries, billed one read per 1,000 replies counted.
82
+ - `subscribe` keeps two snapshot listeners open per page, billed one read per note when they start and one per note changed while they run. A listener that reconnects after being offline for more than 30 minutes is billed as if it started again.
83
+ - Each write is a transaction that reads the note (and a reply's parent, a delete's placeholders above it), and the rules' lookups of other documents are billed as reads too: a few per write.
84
+
85
+ A page with many notes and many visitors can add up. Firestore's own usage tab shows the reads; budget alerts in the Google Cloud console warn before a bill grows.
86
+
87
+ ## Live updates
88
+
89
+ `subscribe` listens to Firestore snapshots of the page's public notes and of the caller's own notes, which the same rules filter. A subscriber hears about its own adapter's writes at once and does not hear them again when the snapshot echoes them. Unlike Supabase Realtime, a note narrowed from public to private, or deleted, is told to other readers as removed.
90
+
91
+ - **Replies are not followed.** A subscription follows one page, and replies target their parent rather than the page, so subscribers hear about a note turning into a placeholder but not about replies being added or edited. Load threads with `threads.listThread` when they are opened.
92
+ - **Who is signed in is read when subscribing.** After someone signs in or out, subscribe again. Reloading the document with the SDK's `setDocument`, as the getting-started guide does when the user changes, does that.
93
+ - **A listener that fails stops.** A visitor subscribing to a scope without `anonymousRead`, or rules that are not deployed, end the subscription; `onListenError` (default `console.error`) is told why.
94
+
95
+ ## Develop
96
+
97
+ The rules and the adapter are tested against the Firestore and Auth emulators, with the shared contract suites and the access-rule tests in `test/`. The emulators need Java 21; `firebase-tools` comes from `pnpm dlx`, at the version pinned in the `test:emulator` script, and downloads the Firestore emulator on first use.
98
+
99
+ ```sh
100
+ cd packages/adapter-firebase
101
+ pnpm test:emulator # starts the emulators from firebase.json, runs the tests, stops them
102
+ ```
103
+
104
+ `scripts/ci-local.sh adapter-firebase` runs the same.
@@ -0,0 +1,96 @@
1
+ import type { Auth } from "firebase/auth";
2
+ import { type Firestore, type FirestoreError } from "firebase/firestore";
3
+ import { type Annotation, type ChangeEvent, type ListQuery, type StorageAdapter, type ThreadStore, type Unsubscribe, type VersionStore } from "@acornly/core";
4
+ export interface FirebaseAdapterOptions {
5
+ /** The site's own Firestore, with the shipped `firestore.rules` deployed to it. */
6
+ firestore: Firestore;
7
+ /**
8
+ * The site's Firebase Auth. The adapter reads and writes as whoever is signed in on it, or as a
9
+ * visitor when nobody is. Anonymous sign-ins count as visitors: they read, but cannot write.
10
+ */
11
+ auth: Auth;
12
+ /**
13
+ * Called when a subscription's snapshot listener fails, for example when a visitor who is not
14
+ * signed in subscribes to a scope without `anonymousRead`, or the rules are not deployed.
15
+ * Firestore stops a failed listener; subscribe again to retry. Defaults to `console.error`.
16
+ */
17
+ onListenError?: (error: FirestoreError) => void;
18
+ }
19
+ /**
20
+ * Keeps annotations in a site's own Firestore, checked by the security rules this package ships
21
+ * (`firestore.rules`). Each write is a Firestore transaction that reads what it depends on, so
22
+ * concurrent edits conflict instead of overwriting each other, and the rules check every write.
23
+ *
24
+ * Subscribers hear about this adapter's own writes at once, and about everyone else's through
25
+ * Firestore snapshot listeners, which the same rules filter.
26
+ */
27
+ export declare class FirebaseAdapter implements StorageAdapter {
28
+ readonly capabilities: {
29
+ realtime: boolean;
30
+ search: boolean;
31
+ offline: boolean;
32
+ };
33
+ private readonly db;
34
+ private readonly auth;
35
+ private readonly notes;
36
+ private readonly listeners;
37
+ private readonly onListenError;
38
+ constructor(options: FirebaseAdapterOptions);
39
+ /** Earlier versions, in `acornly_versions`: only the note's author can save or read them. */
40
+ readonly versions: VersionStore;
41
+ /**
42
+ * Reply threads. Each write is one transaction, and the rules check each new child against
43
+ * its parent and keep every note's list of children in step, so a delete knows whether a note
44
+ * still has children, even ones the deleter cannot read.
45
+ */
46
+ readonly threads: ThreadStore;
47
+ /**
48
+ * Removes `start` in `tx`, then each placeholder above it left with no children, up to
49
+ * `CHAIN_STEP` notes, and takes the last one out of the children of the note above it. `next`
50
+ * is that note when it is a placeholder left with no children, still to remove.
51
+ */
52
+ private removeUp;
53
+ list(q: ListQuery): Promise<Annotation[]>;
54
+ get(id: string): Promise<Annotation | null>;
55
+ create(a: Annotation): Promise<Annotation>;
56
+ update(a: Annotation, expectedRev: number): Promise<Annotation>;
57
+ remove(id: string, expectedRev: number): Promise<void>;
58
+ subscribe(q: Omit<ListQuery, "since">, onChange: (e: ChangeEvent) => void): Unsubscribe;
59
+ /** Who is signed in: `member` when with an account, not an anonymous sign-in. */
60
+ private who;
61
+ /** The signed-in account a write is made as. */
62
+ private writer;
63
+ private noteRef;
64
+ private versionsRef;
65
+ /** The stored note, or null when it is missing or the caller cannot read it. */
66
+ private readDoc;
67
+ /**
68
+ * The notes matching `constraints` that the caller can read: the public ones, and their own.
69
+ * The rules refuse any query that could return a note the caller cannot read, so each query
70
+ * asks for one of those two kinds.
71
+ */
72
+ private readAll;
73
+ /**
74
+ * Runs a query. A visitor is refused a scope without `anonymousRead` as a whole query, which
75
+ * reads as finding nothing, as it would under row-level security.
76
+ */
77
+ private fetch;
78
+ private count;
79
+ /**
80
+ * Runs `body` in a Firestore transaction, which Firestore may retry when what it read changed
81
+ * meanwhile. The rules refusing a write becomes a plain "not allowed".
82
+ */
83
+ private transact;
84
+ /**
85
+ * Plain updates keep the tree: no note moves to another scope, document or thread, `deleted`
86
+ * changes only through `deleteNode`, a placeholder is never edited, a note with children keeps
87
+ * its author, and a child keeps its parent rev and gets no wider than its parent. The rules
88
+ * check the same, but cannot say which rule a write broke.
89
+ */
90
+ private checkTreeKept;
91
+ private onRemote;
92
+ /** Tells this adapter's subscribers about one of its own writes. */
93
+ private emit;
94
+ private tell;
95
+ }
96
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC;AAC1C,OAAO,EAeL,KAAK,SAAS,EACd,KAAK,cAAc,EAIpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAYL,KAAK,UAAU,EACf,KAAK,WAAW,EAEhB,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,sBAAsB;IACrC,mFAAmF;IACnF,SAAS,EAAE,SAAS,CAAC;IACrB;;;OAGG;IACH,IAAI,EAAE,IAAI,CAAC;IACX;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CACjD;AA8ED;;;;;;;GAOG;AACH,qBAAa,eAAgB,YAAW,cAAc;IACpD,QAAQ,CAAC,YAAY;;;;MAAqD;IAC1E,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;IAC5B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAsB;IAC5C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuB;IACjD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkC;gBAEpD,OAAO,EAAE,sBAAsB;IAO3C,6FAA6F;IAC7F,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAyC7B;IAEF;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,WAAW,CAsG3B;IAEF;;;;OAIG;YACW,QAAQ;IAiChB,IAAI,CAAC,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAMzC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAK3C,MAAM,CAAC,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IAyB1C,MAAM,CAAC,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAc/D,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiB5D,SAAS,CAAC,CAAC,EAAE,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,IAAI,GAAG,WAAW;IAgEvF,iFAAiF;YACnE,GAAG;IAMjB,gDAAgD;YAClC,MAAM;IAMpB,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,WAAW;IAInB,gFAAgF;YAClE,OAAO;IAWrB;;;;OAIG;YACW,OAAO;IAgBrB;;;OAGG;YACW,KAAK;YAUL,KAAK;IASnB;;;OAGG;YACW,QAAQ;IAStB;;;;;OAKG;YACW,aAAa;IAqC3B,OAAO,CAAC,QAAQ;IA2BhB,oEAAoE;IACpE,OAAO,CAAC,IAAI;IAoBZ,OAAO,CAAC,IAAI;CAUb"}