@acornly/adapter-supabase 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,106 @@
1
- # Temporary Holding Version
1
+ # @acornly/adapter-supabase
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 Supabase project, with no Acornly Cloud involved. The site pays its own Supabase bill and Acornly charges nothing.
4
+
5
+ The adapter reads and writes as whoever is signed in on the site's Supabase client (Supabase Auth), or as an anonymous visitor when nobody is. Row-level security in the site's database decides what each of them may see and change.
6
+
7
+ ## Set up the database
8
+
9
+ Run the installer, [`supabase/install.sql`](supabase/install.sql), in the site's project, as the database owner: paste it into the dashboard's SQL editor, or `psql -v ON_ERROR_STOP=1 "$DATABASE_URL" -f node_modules/@acornly/adapter-supabase/supabase/install.sql`. Everything it creates is prefixed with `acornly`, so it sits next to the site's own tables.
10
+
11
+ The installer is versioned: it holds every migration in [`supabase/migrations`](supabase/migrations), runs in one transaction, and applies only the ones it has not applied before, recording each in `acornly_meta.migrations` with when it ran. So after upgrading the package, run the new `install.sql` again, as it is; nothing needs copying. Its first line names the last migration it holds.
12
+
13
+ - A project that already has the migrations from `supabase db push` is recognised from the Supabase CLI's history (`supabase_migrations.schema_migrations`), and the installer carries on from there.
14
+ - A project where they were pasted in by hand is refused, since the installer cannot tell which ones it has. Record each file already applied, by the version and name in its file name, then run the installer again:
15
+
16
+ ```sql
17
+ create schema if not exists acornly_meta;
18
+ create table if not exists acornly_meta.migrations (
19
+ version text primary key,
20
+ name text not null,
21
+ applied_at timestamptz not null default now()
22
+ );
23
+ insert into acornly_meta.migrations (version, name) values
24
+ ('20261005090000', 'contexthive_annotations'),
25
+ ('20261005130000', 'contexthive_threads');
26
+ ```
27
+
28
+ - Pick one way and keep to it: the installer, or your own Supabase CLI migrations. Mixing them applies a migration twice.
29
+ - To keep the schema in your own Supabase CLI migrations instead, copy the files in `supabase/migrations` into the site's `supabase/migrations` and run `supabase db push`, as before. They ship in the npm package too, under `node_modules/@acornly/adapter-supabase/supabase/migrations`.
30
+
31
+ Then list the scopes the site accepts notes in. Signed-in users can write only to these, plus their own `user:<id>` scope:
32
+
33
+ ```sql
34
+ insert into public.acornly_scopes (scope) values ('app:my-site');
35
+ -- Let visitors who are not signed in read the public notes on these pages:
36
+ insert into public.acornly_scopes (scope, anonymous_read) values ('web:example.com', true);
37
+ ```
38
+
39
+ ## Use it
40
+
41
+ ```ts
42
+ import { createClient } from "@supabase/supabase-js";
43
+ import { SupabaseAdapter } from "@acornly/adapter-supabase";
44
+
45
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
46
+ const adapter = new SupabaseAdapter({
47
+ client: supabase,
48
+ onRealtimeStatus: (status) => console.debug("live updates:", status),
49
+ });
50
+ ```
51
+
52
+ `@supabase/supabase-js` 2.117 or later in 2.x is a peer dependency: the adapter uses the site's client and adds nothing to the bundle for it.
53
+
54
+ ## Access rules
55
+
56
+ | Who | Can read | Can write |
57
+ | ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
58
+ | The note's creator | Their own notes, always | Edit or remove their own notes, in listed scopes or their own `user:` scope |
59
+ | Anyone signed in | `public` notes | New notes in listed scopes and their own `user:` scope |
60
+ | A visitor who is not signed in | `public` notes in scopes with `anonymous_read` | Nothing |
61
+ | The site's server (`service_role`) | Everything (it bypasses row-level security) | Any note, setting `creator_id` itself; replies under any note |
62
+
63
+ - Supabase's anonymous sign-ins count as visitors who are not signed in, although they have a user id.
64
+ - Two audiences are supported: `private` (only the creator) and `public`. Circles, user lists and links need Acornly Cloud and are refused.
65
+ - Context on context (replies, tags, reactions, highlights on a note) targets its parent: `target.source` is `urn:contexthive:annotation:<encoded parent id>`, and it carries `ext.threadRoot` and `ext.depth`. Add it with `adapter.threads.addChild` (or core's `addContext`). The database checks it against the stored parent, locked for the check: 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 `ext.parentRev` exactly when it quotes the 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.
66
+ - If a note is later narrowed, other people's replies anywhere below it are hidden from everyone who can no longer read it; their creators still see them and can edit (without widening) or delete them. Replies whose parent is gone (its author was deleted) are hidden from everyone but their authors.
67
+ - Plain updates keep the tree: no note moves to another scope, document or thread, `ext.depth`, `ext.parentRev` and a child's 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 deletes refuse replies and notes with replies, even from the raw Data API.
68
+ - Delete with `adapter.threads.deleteNode` (or core's `deleteContext`), which calls `acornly_delete_node`. Only the author (or the site's server) can delete a note. 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.
69
+ - Edit history lives in `acornly_versions` (`adapter.versions`). Only the note's author can save, read or clear its versions; removing a note or turning it into a placeholder drops them.
70
+ - Deleting a user removes their notes. Other people's replies under them stay, and are hidden by `assembleThread` since their parent is gone; a reply under a removed thread root goes with it.
71
+ - The database owns `ext.rev`: a note starts at 1 and each update adds one. Every update and remove checks the expected rev, so of two concurrent edits one wins and the other gets a `ConflictError`.
72
+ - The trigger copies `id`, scope, the document, the parent id, audience, `threadRoot` and `modified` out of the stored annotation into indexed columns, so they always agree. Clients can write only the annotation itself, and cannot move a note to another scope, document or thread.
73
+ - A note's document is its `target.source`, or for a note on a PDF the file's key in `ext.documentKey` (`urn:x-pdf:<fingerprint>`), so the same file opened from another URL lists its notes; `target.source` keeps the URL the note was made on. The key is taken when the note is created and kept on every update, whatever the update says.
74
+ - `modified` comes from the client and is what `list({ since })` compares, so a client with a wrong clock can make its edit invisible to other clients' delta syncs. Treat delta sync as a shortcut and resync with a full `list` now and then.
75
+ - Key fields (`id`, `ext.scope`, `target.source`, `modified`, `ext.threadRoot`) must be strings, and `ext.depth` and `ext.parentRev` numbers; the rest of the annotation is stored as sent, so validate notes from other users before trusting their shape.
76
+ - `creator_id` is the signed-in user who created the note, set by the database. Show that as the author: the annotation's own `creator` field is whatever the client sent.
77
+ - Each note is limited to 64 KB of JSON.
78
+ - 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.
79
+ - The site's server code (the `service_role` key, or the owner in SQL) must set `creator_id` itself. A database function the site writes runs its client callers as clients, even when the function is owned by `postgres`. Other roles count as clients too, so triggers that run as Supabase's auth or storage roles, and custom roles, cannot write notes on a user's behalf.
80
+ - `list` reads in pages of 1,000 rows, the Data API's default `max_rows`. Keep `max_rows` at 1,000 or more, or `list` stops after the first page.
81
+
82
+ ## Realtime
83
+
84
+ `subscribe` listens on a Realtime channel, filtered by document (by scope when the URL has a comma, which Realtime filters cannot match), and Realtime applies the read rules before sending a change. A subscriber hears about its own adapter's writes at once and does not hear them again when Realtime echoes them. A note removed and created again with the same id is told as created again.
85
+
86
+ Things Realtime does not do, which a site should know:
87
+
88
+ - **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.
89
+ - **Removals reach every subscriber.** Realtime sends only the id of a deleted row and cannot filter deletes, so every subscriber, visitors included, learns the id and time of every removal in the table. An id a subscriber never had is a no-op for the store. Use ids that reveal nothing (Acornly's are random ULIDs).
90
+ - **Losing access is silent.** When a note is narrowed from public to private, or a reply is hidden because its root was narrowed, people who could read it get no event and keep showing it until their next `list`.
91
+ - **Nothing is replayed after a drop.** Changes made while the connection is down are lost. When `onRealtimeStatus` reports `SUBSCRIBED` again after another status, call `list` to catch up.
92
+ - **Changes are checked when sent.** Realtime checks each change against the row as it stands when it sends the change, so an insert followed at once by a delete may arrive only as the delete.
93
+
94
+ ## Develop
95
+
96
+ The SQL is tested in this package's own Supabase CLI project (`supabase/config.toml`, project `@acornly/adapter-supabase`), on its own ports (API on 54421, database on 54422), so it runs beside the Acornly Cloud stack in `/supabase` without the schemas ever meeting.
97
+
98
+ ```sh
99
+ cd packages/adapter-supabase
100
+ supabase start -x studio,imgproxy,mailpit,logflare,vector,supavisor,edge-runtime,storage-api,postgres-meta
101
+ pnpm test:db # the shared contract suite and the access-rule tests
102
+ ```
103
+
104
+ A new migration goes in `supabase/migrations`; then regenerate the installer with `pnpm --filter @acornly/adapter-supabase build:install` (`scripts/build-install.mjs`) and commit both. `pnpm test` fails while `install.sql` is out of date (`src/install.test.ts`), and `test/install.test.ts` checks in the stack that it installs the same schema as the migrations, recognises a `supabase db push` project and changes nothing when run again.
105
+
106
+ `test/contract.test.ts` runs `runAdapterContract` and `runThreadContract` from `@acornly/core/testing` as a signed-in user. `test/access.test.ts` signs in as two users and as an anonymous visitor and checks every rule above, including what Realtime delivers. The tests default to the Supabase CLI's local keys; set `API_URL`, `DB_URL`, `ANON_KEY` and `SERVICE_ROLE_KEY` (for example `eval "$(supabase status -o env)"`) to point at another local stack. They wipe the notes table, the scopes and every `@example.test` or anonymous user, so they refuse a non-local stack unless `ACORNLY_TEST_REMOTE=wipe` is set; never point them at a project with real data. Add a test for every new rule, and break the rule on purpose once to watch it fail.
@@ -0,0 +1,75 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { type Annotation, type ChangeEvent, type ListQuery, type StorageAdapter, type ThreadStore, type Unsubscribe, type VersionStore } from "@acornly/core";
3
+ export interface SupabaseAdapterOptions {
4
+ /**
5
+ * The site's own Supabase client. The adapter reads and writes as whoever is signed in on it
6
+ * (Supabase Auth), or as an anonymous visitor when nobody is.
7
+ */
8
+ client: SupabaseClient;
9
+ /**
10
+ * Table in the `public` schema. Defaults to `acornly_annotations`, as the shipped SQL creates.
11
+ * Threads and edit history use the shipped functions and `acornly_versions`, which work on
12
+ * that table only.
13
+ */
14
+ table?: string;
15
+ /**
16
+ * Called as each subscription's Realtime channel changes state: `SUBSCRIBED` once live changes
17
+ * flow, or `CHANNEL_ERROR`, `TIMED_OUT` or `CLOSED` with the reason. Realtime retries on its own;
18
+ * use this to show that live updates are paused.
19
+ */
20
+ onRealtimeStatus?: (status: RealtimeStatus, error?: Error) => void;
21
+ }
22
+ export type RealtimeStatus = "SUBSCRIBED" | "TIMED_OUT" | "CLOSED" | "CHANNEL_ERROR";
23
+ /**
24
+ * Keeps annotations in a site's own Supabase project, in the table the SQL in this package's
25
+ * `supabase/migrations` creates. Row-level security there decides what each user may read and
26
+ * write; the database owns `ext.rev` and each write checks it, so concurrent edits conflict
27
+ * instead of overwriting each other.
28
+ *
29
+ * Subscribers hear about this adapter's own writes at once, and about everyone else's through
30
+ * Supabase Realtime, filtered by the same read rules.
31
+ */
32
+ export declare class SupabaseAdapter implements StorageAdapter {
33
+ readonly capabilities: {
34
+ realtime: boolean;
35
+ search: boolean;
36
+ offline: boolean;
37
+ };
38
+ private readonly client;
39
+ private readonly table;
40
+ private readonly listeners;
41
+ private readonly onRealtimeStatus;
42
+ constructor(options: SupabaseAdapterOptions);
43
+ /** Earlier versions, in `acornly_versions`: only the note's author can save or read them. */
44
+ readonly versions: VersionStore;
45
+ /**
46
+ * Reply threads. The database checks each new child against its locked parent, and deletes run
47
+ * in `acornly_delete_node`, which may remove placeholders other people wrote.
48
+ */
49
+ readonly threads: ThreadStore;
50
+ list(q: ListQuery): Promise<Annotation[]>;
51
+ get(id: string): Promise<Annotation | null>;
52
+ create(a: Annotation): Promise<Annotation>;
53
+ update(a: Annotation, expectedRev: number): Promise<Annotation>;
54
+ remove(id: string, expectedRev: number): Promise<void>;
55
+ /**
56
+ * Runs `request`, which removes `id` and says where it was, or returns null when it removed
57
+ * nothing. While it runs, every subscriber tracks the remove so Realtime's echo of it, and the
58
+ * changes from before it, are not told after it. Tells subscribers once it succeeds.
59
+ */
60
+ private trackRemove;
61
+ subscribe(q: Omit<ListQuery, "since">, onChange: (e: ChangeEvent) => void): Unsubscribe;
62
+ /**
63
+ * A write matched no row: the note is missing (or hidden from the caller), its rev moved on,
64
+ * or the caller may read it but not change it.
65
+ */
66
+ private explainMiss;
67
+ private onRemote;
68
+ /**
69
+ * Tells this adapter's subscribers about one of its own writes, except `skip`: those already
70
+ * heard about it from Realtime.
71
+ */
72
+ private emit;
73
+ private tell;
74
+ }
75
+ //# 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,EAGV,cAAc,EACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAQL,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;;;OAGG;IACH,MAAM,EAAE,cAAc,CAAC;IACvB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC;CACpE;AAED,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,GAAG,eAAe,CAAC;AA2DrF;;;;;;;;GAQG;AACH,qBAAa,eAAgB,YAAW,cAAc;IACpD,QAAQ,CAAC,YAAY;;;;MAAqD;IAC1E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuB;IACjD,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA6C;gBAElE,OAAO,EAAE,sBAAsB;IAM3C,6FAA6F;IAC7F,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAqB7B;IAEF;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,WAAW,CAsE3B;IAEI,IAAI,CAAC,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAoBzC,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAU3C,MAAM,CAAC,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;IAkB1C,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;IAe5D;;;;OAIG;YACW,WAAW;IAsCzB,SAAS,CAAC,CAAC,EAAE,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,IAAI,GAAG,WAAW;IAmCvF;;;OAGG;YACW,WAAW;IAYzB,OAAO,CAAC,QAAQ;IAiDhB;;;OAGG;IACH,OAAO,CAAC,IAAI;IAuBZ,OAAO,CAAC,IAAI;CAUb"}
package/dist/index.js ADDED
@@ -0,0 +1,440 @@
1
+ import { afterAnnotationPrefix, AudienceError, ConflictError, NotFoundError, ThreadError, documentKeyOf, parentIdOf, } from "@acornly/core";
2
+ /**
3
+ * Rows per request when listing. Matches the Data API's default `max_rows`; a project that lowers
4
+ * `max_rows` below this would see `list` stop after the first page.
5
+ */
6
+ const PAGE = 1000;
7
+ /** How long to wait for the Realtime echo of an own remove before giving up on it. */
8
+ const ECHO_WAIT_MS = 30_000;
9
+ let channelCount = 0;
10
+ /**
11
+ * Keeps annotations in a site's own Supabase project, in the table the SQL in this package's
12
+ * `supabase/migrations` creates. Row-level security there decides what each user may read and
13
+ * write; the database owns `ext.rev` and each write checks it, so concurrent edits conflict
14
+ * instead of overwriting each other.
15
+ *
16
+ * Subscribers hear about this adapter's own writes at once, and about everyone else's through
17
+ * Supabase Realtime, filtered by the same read rules.
18
+ */
19
+ export class SupabaseAdapter {
20
+ capabilities = { realtime: true, search: false, offline: false };
21
+ client;
22
+ table;
23
+ listeners = new Set();
24
+ onRealtimeStatus;
25
+ constructor(options) {
26
+ this.client = options.client;
27
+ this.table = options.table ?? "acornly_annotations";
28
+ this.onRealtimeStatus = options.onRealtimeStatus;
29
+ }
30
+ /** Earlier versions, in `acornly_versions`: only the note's author can save or read them. */
31
+ versions = {
32
+ saveVersion: async (a) => {
33
+ const { error } = await this.client
34
+ .from("acornly_versions")
35
+ .upsert({ annotation: a }, { onConflict: "note_id,rev" });
36
+ if (error)
37
+ throw toError(error);
38
+ },
39
+ listVersions: async (id) => {
40
+ const { data, error } = await this.client
41
+ .from("acornly_versions")
42
+ .select("annotation")
43
+ .eq("note_id", id)
44
+ .order("rev")
45
+ .returns();
46
+ if (error)
47
+ throw toError(error);
48
+ return data.map((row) => row.annotation);
49
+ },
50
+ clearVersions: async (id) => {
51
+ const { error } = await this.client.from("acornly_versions").delete().eq("note_id", id);
52
+ if (error)
53
+ throw toError(error);
54
+ },
55
+ };
56
+ /**
57
+ * Reply threads. The database checks each new child against its locked parent, and deletes run
58
+ * in `acornly_delete_node`, which may remove placeholders other people wrote.
59
+ */
60
+ threads = {
61
+ listThread: async (q) => {
62
+ // One call: the function returns the whole thread as one JSON array.
63
+ const { data, error } = await this.client.rpc("acornly_list_thread", {
64
+ root_id: q.threadRoot,
65
+ thread_scope: q.scope,
66
+ });
67
+ if (error)
68
+ throw toError(error);
69
+ return data;
70
+ },
71
+ countChildren: async (ids) => {
72
+ const counts = Object.fromEntries(ids.map((id) => [id, 0]));
73
+ if (!ids.length)
74
+ return counts;
75
+ const { data, error } = await this.client.rpc("acornly_count_children", {
76
+ ids: [...new Set(ids)],
77
+ });
78
+ if (error)
79
+ throw toError(error);
80
+ for (const row of data) {
81
+ counts[row.id] = Number(row.children);
82
+ }
83
+ return counts;
84
+ },
85
+ addChild: async (child) => {
86
+ const parentId = parentIdOf(child);
87
+ if (parentId === null)
88
+ throw new Error(`Annotation ${child.id} is not context on context`);
89
+ const { data, error } = await this.client
90
+ .from(this.table)
91
+ .insert({ annotation: child })
92
+ .select("annotation")
93
+ .single();
94
+ if (error) {
95
+ if (error.code === "P0002")
96
+ throw new NotFoundError(parentId);
97
+ throw error.code === "23505"
98
+ ? new Error(`Annotation ${child.id} already exists`, { cause: error })
99
+ : toError(error);
100
+ }
101
+ this.emit({ type: "created", annotation: data.annotation });
102
+ return data.annotation;
103
+ },
104
+ deleteNode: async (id, expectedRev, now) => {
105
+ let result;
106
+ await this.trackRemove(id, async () => {
107
+ const { data, error } = await this.client.rpc("acornly_delete_node", {
108
+ note_id: id,
109
+ expected_rev: expectedRev,
110
+ deleted_at: now,
111
+ });
112
+ if (error) {
113
+ if (error.code === "P0002")
114
+ throw new NotFoundError(id);
115
+ if (error.code === "CH003") {
116
+ throw new ConflictError(id, expectedRev, Number(error.details));
117
+ }
118
+ if (error.code === "42501") {
119
+ throw new Error(`Not allowed to change annotation ${id}`, { cause: error });
120
+ }
121
+ throw toError(error);
122
+ }
123
+ result = data;
124
+ return "placeholder" in result ? null : result.removed[0];
125
+ });
126
+ if ("placeholder" in result) {
127
+ this.emit({ type: "updated", annotation: result.placeholder });
128
+ return { placeholder: result.placeholder, removed: [] };
129
+ }
130
+ // The note itself was told above; these are the placeholders that went with it.
131
+ for (const above of result.removed.slice(1)) {
132
+ this.emit({ type: "removed", id: above.id }, above);
133
+ }
134
+ return { removed: result.removed.map((r) => r.id) };
135
+ },
136
+ };
137
+ async list(q) {
138
+ const found = [];
139
+ for (let from = 0;; from += PAGE) {
140
+ let query = this.client
141
+ .from(this.table)
142
+ .select("annotation")
143
+ .eq("scope", q.scope)
144
+ .eq("document", q.documentId);
145
+ if (q.since !== undefined)
146
+ query = query.gt("modified", q.since);
147
+ const { data, error } = await query
148
+ .order("modified")
149
+ .order("id")
150
+ .range(from, from + PAGE - 1)
151
+ .returns();
152
+ if (error)
153
+ throw toError(error);
154
+ for (const row of data)
155
+ found.push(row.annotation);
156
+ if (data.length < PAGE)
157
+ return found;
158
+ }
159
+ }
160
+ async get(id) {
161
+ const { data, error } = await this.client
162
+ .from(this.table)
163
+ .select("annotation")
164
+ .eq("id", id)
165
+ .maybeSingle();
166
+ if (error)
167
+ throw toError(error);
168
+ return data?.annotation ?? null;
169
+ }
170
+ async create(a) {
171
+ if (isAnnotationTarget(a.target.source)) {
172
+ throw new ThreadError(`Annotation ${a.id} targets an annotation; use threads.addChild`);
173
+ }
174
+ const { data, error } = await this.client
175
+ .from(this.table)
176
+ .insert({ annotation: a })
177
+ .select("annotation")
178
+ .single();
179
+ if (error) {
180
+ throw error.code === "23505"
181
+ ? new Error(`Annotation ${a.id} already exists`, { cause: error })
182
+ : toError(error);
183
+ }
184
+ this.emit({ type: "created", annotation: data.annotation });
185
+ return data.annotation;
186
+ }
187
+ async update(a, expectedRev) {
188
+ const { data, error } = await this.client
189
+ .from(this.table)
190
+ .update({ annotation: a })
191
+ .eq("id", a.id)
192
+ .eq("rev", expectedRev)
193
+ .select("annotation")
194
+ .maybeSingle();
195
+ if (error)
196
+ throw toError(error);
197
+ if (!data)
198
+ return this.explainMiss(a.id, expectedRev);
199
+ this.emit({ type: "updated", annotation: data.annotation });
200
+ return data.annotation;
201
+ }
202
+ async remove(id, expectedRev) {
203
+ const removed = await this.trackRemove(id, async () => {
204
+ const { data, error } = await this.client
205
+ .from(this.table)
206
+ .delete()
207
+ .eq("id", id)
208
+ .eq("rev", expectedRev)
209
+ .select("scope, document")
210
+ .maybeSingle();
211
+ if (error)
212
+ throw toError(error);
213
+ return data;
214
+ });
215
+ if (!removed)
216
+ return this.explainMiss(id, expectedRev);
217
+ }
218
+ /**
219
+ * Runs `request`, which removes `id` and says where it was, or returns null when it removed
220
+ * nothing. While it runs, every subscriber tracks the remove so Realtime's echo of it, and the
221
+ * changes from before it, are not told after it. Tells subscribers once it succeeds.
222
+ */
223
+ async trackRemove(id, request) {
224
+ // Every subscriber gets the delete's echo, whatever its document, so all of them track it.
225
+ const tracked = new Map();
226
+ for (const l of this.listeners) {
227
+ if (l.removes.has(id))
228
+ continue;
229
+ const pending = { state: "sent" };
230
+ l.removes.set(id, pending);
231
+ tracked.set(l, pending);
232
+ }
233
+ let removed = null;
234
+ const alreadyTold = new Set();
235
+ try {
236
+ removed = await request();
237
+ }
238
+ finally {
239
+ for (const [l, pending] of tracked) {
240
+ // Forgotten when the channel rejoined.
241
+ if (l.removes.get(id) !== pending)
242
+ continue;
243
+ if (!removed) {
244
+ // Nothing was removed, so what arrived meanwhile is current: tell it now.
245
+ l.removes.delete(id);
246
+ if (pending.held)
247
+ this.onRemote(l, pending.held.change);
248
+ }
249
+ else if (pending.state === "echoed") {
250
+ l.removes.delete(id);
251
+ alreadyTold.add(l);
252
+ }
253
+ else {
254
+ pending.state = "due";
255
+ pending.dueAt = Date.now();
256
+ delete pending.held;
257
+ }
258
+ }
259
+ }
260
+ if (removed)
261
+ this.emit({ type: "removed", id }, removed, alreadyTold);
262
+ return removed;
263
+ }
264
+ subscribe(q, onChange) {
265
+ const listener = {
266
+ documentId: q.documentId,
267
+ scope: q.scope,
268
+ onChange,
269
+ delivered: new Map(),
270
+ removes: new Map(),
271
+ live: false,
272
+ stopped: false,
273
+ };
274
+ this.listeners.add(listener);
275
+ const table = { schema: "public", table: this.table };
276
+ const filter = filterFor(q);
277
+ const onRemote = (change) => this.onRemote(listener, change);
278
+ const channel = this.client
279
+ .channel(`acornly:${this.table}:${++channelCount}`)
280
+ // Realtime can filter inserts and updates by column, but not deletes.
281
+ .on("postgres_changes", { event: "INSERT", ...table, ...filter }, onRemote)
282
+ .on("postgres_changes", { event: "UPDATE", ...table, ...filter }, onRemote)
283
+ .on("postgres_changes", { event: "DELETE", ...table }, onRemote)
284
+ .subscribe((status, error) => {
285
+ if (listener.stopped)
286
+ return;
287
+ // Changes made before the channel was (re)joined are not replayed, echoes included:
288
+ // stop waiting for them. After a drop the app should `list` again once it is back.
289
+ if (status !== "SUBSCRIBED" || !listener.live)
290
+ listener.removes.clear();
291
+ listener.live = status === "SUBSCRIBED";
292
+ this.onRealtimeStatus?.(status, error);
293
+ });
294
+ return () => {
295
+ listener.stopped = true;
296
+ this.listeners.delete(listener);
297
+ void this.client.removeChannel(channel);
298
+ };
299
+ }
300
+ /**
301
+ * A write matched no row: the note is missing (or hidden from the caller), its rev moved on,
302
+ * or the caller may read it but not change it.
303
+ */
304
+ async explainMiss(id, expectedRev) {
305
+ const { data, error } = await this.client
306
+ .from(this.table)
307
+ .select("rev")
308
+ .eq("id", id)
309
+ .maybeSingle();
310
+ if (error)
311
+ throw toError(error);
312
+ if (!data)
313
+ throw new NotFoundError(id);
314
+ if (data.rev !== expectedRev)
315
+ throw new ConflictError(id, expectedRev, data.rev);
316
+ throw new Error(`Not allowed to change annotation ${id}`);
317
+ }
318
+ onRemote(listener, change) {
319
+ if (listener.stopped)
320
+ return;
321
+ const id = change.eventType === "DELETE" ? change.old.id : change.new.annotation?.id;
322
+ if (!id)
323
+ return;
324
+ let own = listener.removes.get(id);
325
+ if (own?.state === "due" && Date.now() - own.dueAt > ECHO_WAIT_MS) {
326
+ // The echo never came (a dropped message); stop waiting for it.
327
+ listener.removes.delete(id);
328
+ own = undefined;
329
+ }
330
+ if (change.eventType === "DELETE") {
331
+ if (own?.state === "due") {
332
+ // The echo of this adapter's own remove, already told.
333
+ listener.removes.delete(id);
334
+ return;
335
+ }
336
+ if (own?.state === "sent") {
337
+ own.state = "echoed";
338
+ delete own.held;
339
+ }
340
+ if (listener.delivered.get(id) === "removed")
341
+ return;
342
+ // Realtime sends only the id of a deleted row, so every subscriber hears about it and an id
343
+ // it never had is a no-op for it. Only ids it has heard of, or is removing, are remembered.
344
+ if (own || listener.delivered.has(id))
345
+ listener.delivered.set(id, "removed");
346
+ this.tell(listener, { type: "removed", id });
347
+ return;
348
+ }
349
+ const annotation = change.new.annotation;
350
+ if (!matches(listener, documentKeyOf(annotation), annotation.ext.scope))
351
+ return;
352
+ if (own?.state === "sent") {
353
+ // The change that would make the remove fail, or one from before it: decide once it settles.
354
+ if (!own.held || own.held.rev <= annotation.ext.rev) {
355
+ own.held = { change, rev: annotation.ext.rev };
356
+ }
357
+ return;
358
+ }
359
+ // From before this adapter's own remove of the note.
360
+ if (own?.state === "due")
361
+ return;
362
+ const last = listener.delivered.get(id);
363
+ if (change.eventType === "UPDATE" && last === "removed")
364
+ return;
365
+ if (typeof last === "number" && last >= annotation.ext.rev)
366
+ return;
367
+ // An insert after a removal is the same id created again.
368
+ listener.delivered.set(id, annotation.ext.rev);
369
+ this.tell(listener, {
370
+ type: change.eventType === "INSERT" ? "created" : "updated",
371
+ annotation,
372
+ });
373
+ }
374
+ /**
375
+ * Tells this adapter's subscribers about one of its own writes, except `skip`: those already
376
+ * heard about it from Realtime.
377
+ */
378
+ emit(event, where, skip) {
379
+ const document = where?.document ?? documentKeyOf(event.annotation);
380
+ const scope = where?.scope ?? event.annotation.ext.scope;
381
+ for (const l of this.listeners) {
382
+ if (skip?.has(l) || !matches(l, document, scope))
383
+ continue;
384
+ if (event.type === "removed") {
385
+ if (l.delivered.get(event.id) === "removed")
386
+ continue;
387
+ l.delivered.set(event.id, "removed");
388
+ }
389
+ else {
390
+ const last = l.delivered.get(event.annotation.id);
391
+ // Realtime's echo beat the response: already told.
392
+ if (typeof last === "number" && last >= event.annotation.ext.rev)
393
+ continue;
394
+ l.delivered.set(event.annotation.id, event.annotation.ext.rev);
395
+ }
396
+ this.tell(l, event);
397
+ }
398
+ }
399
+ tell(listener, event) {
400
+ // A throwing listener must not fail a write that has already committed.
401
+ try {
402
+ listener.onChange(structuredClone(event));
403
+ }
404
+ catch (err) {
405
+ queueMicrotask(() => {
406
+ throw err;
407
+ });
408
+ }
409
+ }
410
+ }
411
+ function matches(l, document, scope) {
412
+ return l.documentId === document && l.scope === scope;
413
+ }
414
+ /**
415
+ * The Realtime filter for a subscription. Realtime cannot match a value containing a comma, so
416
+ * such a document falls back to its scope, and a scope with a comma to no filter; `matches`
417
+ * always checks both on arrival.
418
+ */
419
+ function filterFor(q) {
420
+ if (!q.documentId.includes(","))
421
+ return { filter: `document=eq.${q.documentId}` };
422
+ if (!q.scope.includes(","))
423
+ return { filter: `scope=eq.${q.scope}` };
424
+ return {};
425
+ }
426
+ /** Maps the shipped SQL's error codes to core's errors. */
427
+ function toError(error) {
428
+ if (error.code === "CH001")
429
+ return new ThreadError(error.message);
430
+ if (error.code === "CH002")
431
+ return new AudienceError(error.message);
432
+ return new Error(error.message, { cause: error });
433
+ }
434
+ function isAnnotationTarget(source) {
435
+ // The database reports a source that is not a string.
436
+ if (typeof source !== "string")
437
+ return false;
438
+ return afterAnnotationPrefix(source) !== undefined;
439
+ }
440
+ //# sourceMappingURL=index.js.map