@acornly/adapter-supabase 0.1.0 → 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.
@@ -0,0 +1,2295 @@
1
+ -- @acornly/adapter-supabase installer, through migration 20261011170000.
2
+ --
3
+ -- Generated by scripts/build-install.mjs from supabase/migrations; do not edit it by hand.
4
+ --
5
+ -- Run the whole file in the project's database, as the owner: paste it into the dashboard's SQL
6
+ -- editor, or `psql -v ON_ERROR_STOP=1 "$DATABASE_URL" -f install.sql`. It runs in one transaction and applies only
7
+ -- the migrations it has not applied before, so run it again, as it is, after each upgrade of the
8
+ -- package. acornly_meta.migrations records what it applied, and when.
9
+ --
10
+ -- A database that already has the migrations from `supabase db push` is recognised from the
11
+ -- Supabase CLI's history. One where they were pasted in by hand is refused until you record which
12
+ -- ones it has (the error says how).
13
+
14
+ begin;
15
+
16
+ -- One installer at a time.
17
+ select pg_advisory_xact_lock(hashtext('acornly_install'));
18
+
19
+ create schema if not exists acornly_meta;
20
+ revoke all on schema acornly_meta from public;
21
+ create table if not exists acornly_meta.migrations (
22
+ version text primary key,
23
+ name text not null,
24
+ applied_at timestamptz not null default now()
25
+ );
26
+ revoke all on table acornly_meta.migrations from public;
27
+
28
+ do $acornly_install$
29
+ begin
30
+ if exists (select 1 from acornly_meta.migrations) then
31
+ return;
32
+ end if;
33
+ -- Applied by `supabase db push` from copies of these files: the same version, and the same
34
+ -- name where the CLI records one, so a site's own migration with the same version is not taken
35
+ -- for Acornly's.
36
+ if to_regclass('supabase_migrations.schema_migrations') is not null then
37
+ if exists (select 1 from information_schema.columns c
38
+ where c.table_schema = 'supabase_migrations' and c.table_name = 'schema_migrations'
39
+ and c.column_name = 'name') then
40
+ insert into acornly_meta.migrations (version, name)
41
+ select v.version, v.name
42
+ from (values
43
+ ('20261005090000', 'contexthive_annotations'),
44
+ ('20261005130000', 'contexthive_threads'),
45
+ ('20261010070000', 'acornly_names'),
46
+ ('20261011080000', 'acornly_delete_node_grants'),
47
+ ('20261011150000', 'acornly_parent_ids'),
48
+ ('20261011170000', 'acornly_pdf_document_keys')) v (version, name)
49
+ where exists (select 1 from supabase_migrations.schema_migrations h
50
+ where h.version = v.version and h.name = v.name);
51
+ else
52
+ insert into acornly_meta.migrations (version, name)
53
+ select v.version, v.name
54
+ from (values
55
+ ('20261005090000', 'contexthive_annotations'),
56
+ ('20261005130000', 'contexthive_threads'),
57
+ ('20261010070000', 'acornly_names'),
58
+ ('20261011080000', 'acornly_delete_node_grants'),
59
+ ('20261011150000', 'acornly_parent_ids'),
60
+ ('20261011170000', 'acornly_pdf_document_keys')) v (version, name)
61
+ where v.version in (select h.version from supabase_migrations.schema_migrations h);
62
+ end if;
63
+ end if;
64
+ if not exists (select 1 from acornly_meta.migrations)
65
+ and (to_regclass('public.acornly_annotations') is not null
66
+ or to_regclass('public.contexthive_annotations') is not null) then
67
+ raise exception 'Acornly''s tables are here, but not which of its migrations made them'
68
+ using hint = 'Record each migration file already applied, then run the installer again: '
69
+ || 'create schema if not exists acornly_meta; '
70
+ || 'create table if not exists acornly_meta.migrations (version text primary key, name text not null, '
71
+ || 'applied_at timestamptz not null default now()); '
72
+ || 'insert into acornly_meta.migrations (version, name) values (''<version>'', ''<name>''); '
73
+ || 'the version and name are the file''s name, as in 20261005090000_contexthive_annotations.sql.';
74
+ end if;
75
+ end
76
+ $acornly_install$;
77
+
78
+ -- 20261005090000_contexthive_annotations.sql
79
+ do $acornly_install$
80
+ begin
81
+ if not exists (select 1 from acornly_meta.migrations where version = '20261005090000') then
82
+ raise notice 'acornly: applying %', '20261005090000_contexthive_annotations.sql';
83
+ execute $acornly_migration$
84
+ -- ContextHive notes in a site's own Supabase project (contexthive-adapter-supabase).
85
+ --
86
+ -- This is not the ContextHive Cloud schema in /supabase: a site that runs notes
87
+ -- on its own project applies this file (and later files in this folder) to that
88
+ -- project. Everything it creates is prefixed with `contexthive`, so it can sit
89
+ -- next to the site's own tables.
90
+ --
91
+ -- Rules this migration encodes:
92
+ --
93
+ -- * The stored W3C annotation (`annotation`) is the source of truth. The
94
+ -- columns the policies and indexes use (`id`, `scope`, `document`,
95
+ -- `audience`, `thread_root_id`, `modified`) are copied from it by a trigger
96
+ -- on every write, so a client can never make them disagree.
97
+ -- * The database owns `rev`: a new note starts at 1 and every update adds one.
98
+ -- The adapter updates `where rev = <expected>`, so one of two concurrent
99
+ -- edits wins and the other sees a conflict.
100
+ -- * Clients may write only the `annotation` column. `creator_id` is the
101
+ -- signed-in user, set by the trigger, and is the author a site should trust
102
+ -- (the annotation's own `creator` field is whatever the client sent).
103
+ -- * A signed-in user may write only to scopes the site lists in
104
+ -- `contexthive_scopes`, plus their own `user:<id>` scope.
105
+ -- * Audiences: `private` (only the creator reads it) and `public` (everyone
106
+ -- signed in reads it; anonymous visitors too where the scope allows it).
107
+ -- Other audience kinds need ContextHive Cloud and are refused.
108
+ -- * Replies (`ext.threadRoot` set) live in their root's scope and document,
109
+ -- can narrow the root's audience but never widen it, and are hidden from
110
+ -- others whenever they cannot read the root.
111
+
112
+ -- ---------------------------------------------------------------------------
113
+ -- Internal schema: helpers for the policies and the trigger. It is not in the
114
+ -- API's exposed schemas, and only the helpers below are executable by clients.
115
+ -- ---------------------------------------------------------------------------
116
+
117
+ create schema if not exists contexthive_private;
118
+ revoke all on schema contexthive_private from public;
119
+ grant usage on schema contexthive_private to anon, authenticated, service_role;
120
+
121
+ -- ---------------------------------------------------------------------------
122
+ -- Tables
123
+ -- ---------------------------------------------------------------------------
124
+
125
+ -- The app and web scopes this site accepts notes in, managed by the site with
126
+ -- SQL or the dashboard (clients have no access). For example:
127
+ -- insert into public.contexthive_scopes (scope) values ('app:my-site');
128
+ -- insert into public.contexthive_scopes (scope, anonymous_read)
129
+ -- values ('web:example.com', true);
130
+ create table public.contexthive_scopes (
131
+ scope text primary key check (scope ~ '^(app|web):.+$'),
132
+ -- Whether visitors who are not signed in can read the public notes in this scope.
133
+ anonymous_read boolean not null default false
134
+ );
135
+
136
+ alter table public.contexthive_scopes enable row level security;
137
+ revoke all on table public.contexthive_scopes from anon, authenticated;
138
+
139
+ create table public.contexthive_annotations (
140
+ id text primary key check (char_length(id) between 1 and 255),
141
+ -- `app:<appId>`, `web:<registrable domain>` or `user:<user id>`.
142
+ scope text not null check (scope ~ '^(app|web|user):.+$'),
143
+ -- The `<appId>` of an app scope, for the site's own queries.
144
+ app_id text generated always as (
145
+ case when scope like 'app:%' then substr(scope, 5) end
146
+ ) stored,
147
+ -- The canonical document URL, `target.source`.
148
+ document text not null check (document <> ''),
149
+ creator_id uuid not null references auth.users (id) on delete cascade,
150
+ audience text not null check (audience in ('private', 'public')),
151
+ thread_root_id text references public.contexthive_annotations (id) on delete cascade,
152
+ modified timestamptz not null,
153
+ rev integer not null check (rev >= 1),
154
+ annotation jsonb not null check (
155
+ jsonb_typeof(annotation) = 'object' and octet_length(annotation::text) <= 65536
156
+ ),
157
+
158
+ constraint contexthive_annotations_user_scope_is_creator check (
159
+ scope !~ '^user:' or scope = 'user:' || creator_id::text
160
+ ),
161
+ constraint contexthive_annotations_not_own_root check (thread_root_id is distinct from id)
162
+ );
163
+
164
+ -- `list`: one document's notes in one scope, optionally modified after a time.
165
+ create index contexthive_annotations_page_idx
166
+ on public.contexthive_annotations (scope, document, modified);
167
+ -- Delta sync across documents.
168
+ create index contexthive_annotations_modified_idx on public.contexthive_annotations (modified);
169
+ create index contexthive_annotations_thread_idx
170
+ on public.contexthive_annotations (thread_root_id) where thread_root_id is not null;
171
+ -- Deleting a user cascades to their notes.
172
+ create index contexthive_annotations_creator_idx on public.contexthive_annotations (creator_id);
173
+
174
+ -- ---------------------------------------------------------------------------
175
+ -- Helpers
176
+ -- ---------------------------------------------------------------------------
177
+
178
+ -- Whether the caller is a signed-in user. Supabase's anonymous sign-ins get a
179
+ -- user id too, but count as visitors here: they read like anyone not signed in
180
+ -- and write nothing.
181
+ create function contexthive_private.signed_in()
182
+ returns boolean
183
+ language sql
184
+ stable
185
+ set search_path = ''
186
+ as $$
187
+ select auth.uid() is not null
188
+ and not coalesce((auth.jwt() ->> 'is_anonymous')::boolean, false)
189
+ $$;
190
+
191
+ -- Whether the request comes from the site's own server code: the service_role
192
+ -- key, or the owner in SQL. Every other role is a client. Decided from the
193
+ -- request's role, which a security-definer function does not change, so a
194
+ -- site function owned by postgres still counts its client caller as a client.
195
+ create function contexthive_private.trusted_caller()
196
+ returns boolean
197
+ language sql
198
+ stable
199
+ set search_path = ''
200
+ as $$
201
+ select coalesce(nullif(current_setting('role', true), 'none'), session_user::text)
202
+ in ('service_role', 'postgres', 'supabase_admin')
203
+ $$;
204
+
205
+ -- Whether the caller may read public notes in `note_scope`: anyone signed in,
206
+ -- and anonymous visitors where the site turned on `anonymous_read`.
207
+ create function contexthive_private.public_readable(note_scope text)
208
+ returns boolean
209
+ language sql
210
+ stable
211
+ security definer
212
+ set search_path = ''
213
+ as $$
214
+ select contexthive_private.signed_in()
215
+ or exists (
216
+ select 1 from public.contexthive_scopes s
217
+ where s.scope = note_scope and s.anonymous_read
218
+ )
219
+ $$;
220
+
221
+ -- Whether the caller may read the thread root `root_id`. Roots have no root of
222
+ -- their own, so this never recurses.
223
+ create function contexthive_private.root_readable(root_id text)
224
+ returns boolean
225
+ language sql
226
+ stable
227
+ security definer
228
+ set search_path = ''
229
+ as $$
230
+ select exists (
231
+ select 1 from public.contexthive_annotations r
232
+ where r.id = root_id
233
+ and (
234
+ r.creator_id = auth.uid()
235
+ or (r.audience = 'public' and contexthive_private.public_readable(r.scope))
236
+ )
237
+ )
238
+ $$;
239
+
240
+ -- Whether the signed-in caller may write notes in `note_scope`.
241
+ create function contexthive_private.scope_writable(note_scope text)
242
+ returns boolean
243
+ language sql
244
+ stable
245
+ security definer
246
+ set search_path = ''
247
+ as $$
248
+ select contexthive_private.signed_in()
249
+ and (
250
+ note_scope = 'user:' || auth.uid()::text
251
+ or exists (select 1 from public.contexthive_scopes s where s.scope = note_scope)
252
+ )
253
+ $$;
254
+
255
+ -- Raises unless a reply in `reply_scope` and `reply_document` with audience
256
+ -- `reply_audience` may hang off `root_id`. Reads the root as the owner, but
257
+ -- reports a root the caller cannot read, or a reply, exactly like a missing
258
+ -- one. A trusted caller may reply under any root.
259
+ create function contexthive_private.check_thread_root(
260
+ root_id text,
261
+ reply_scope text,
262
+ reply_document text,
263
+ reply_audience text
264
+ )
265
+ returns void
266
+ language plpgsql
267
+ stable
268
+ security definer
269
+ set search_path = ''
270
+ as $$
271
+ declare
272
+ root public.contexthive_annotations;
273
+ begin
274
+ select * into root from public.contexthive_annotations r where r.id = root_id;
275
+ if not found
276
+ or root.thread_root_id is not null
277
+ or (
278
+ not contexthive_private.trusted_caller()
279
+ and not (
280
+ root.creator_id = auth.uid()
281
+ or (root.audience = 'public' and contexthive_private.public_readable(root.scope))
282
+ )
283
+ )
284
+ then
285
+ raise exception 'thread root % not found', root_id using errcode = '23503';
286
+ end if;
287
+ if root.scope <> reply_scope or root.document <> reply_document then
288
+ raise exception 'a reply must be in its thread root''s scope and document'
289
+ using errcode = '23514';
290
+ end if;
291
+ if root.audience = 'private' and reply_audience <> 'private' then
292
+ raise exception 'a reply cannot widen its thread root''s audience' using errcode = '23514';
293
+ end if;
294
+ end
295
+ $$;
296
+
297
+ revoke all on all functions in schema contexthive_private from public;
298
+ grant execute on function
299
+ contexthive_private.signed_in(),
300
+ contexthive_private.trusted_caller(),
301
+ contexthive_private.public_readable(text),
302
+ contexthive_private.root_readable(text),
303
+ contexthive_private.scope_writable(text),
304
+ contexthive_private.check_thread_root(text, text, text, text)
305
+ to anon, authenticated, service_role;
306
+
307
+ -- ---------------------------------------------------------------------------
308
+ -- Write trigger: copies the indexed columns out of the annotation, owns
309
+ -- `creator_id` and `rev`, and checks threads. Runs as the caller, so the
310
+ -- policies below still apply to the row it produces.
311
+ -- ---------------------------------------------------------------------------
312
+
313
+ create function contexthive_private.prepare_annotation()
314
+ returns trigger
315
+ language plpgsql
316
+ set search_path = ''
317
+ as $$
318
+ declare
319
+ a jsonb := new.annotation;
320
+ audience_kind text := a #>> '{ext,audience,kind}';
321
+ -- The site's own server code writing on a user's behalf.
322
+ trusted boolean := contexthive_private.trusted_caller();
323
+ begin
324
+ if jsonb_typeof(a -> 'id') is distinct from 'string'
325
+ or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
326
+ or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
327
+ or jsonb_typeof(a -> 'modified') is distinct from 'string'
328
+ -- ISO 8601 only, so no `infinity`, `now` or other special values.
329
+ or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
330
+ or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
331
+ then
332
+ raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
333
+ using errcode = '23514';
334
+ end if;
335
+
336
+ if tg_op = 'INSERT' then
337
+ if not trusted then
338
+ -- Clients cannot write this column; the author is always the caller.
339
+ new.creator_id := auth.uid();
340
+ if new.creator_id is null then
341
+ raise exception 'sign in to write notes' using errcode = '42501';
342
+ end if;
343
+ elsif new.creator_id is null then
344
+ raise exception 'creator_id is required' using errcode = '23502';
345
+ end if;
346
+ new.rev := 1;
347
+ else
348
+ new.creator_id := old.creator_id;
349
+ new.rev := old.rev + 1;
350
+ end if;
351
+
352
+ if audience_kind is null or audience_kind not in ('private', 'public') then
353
+ raise exception 'audience "%" is not supported here; use private or public', audience_kind
354
+ using errcode = '23514';
355
+ end if;
356
+
357
+ new.id := a ->> 'id';
358
+ new.scope := a #>> '{ext,scope}';
359
+ new.document := a #>> '{target,source}';
360
+ new.audience := audience_kind;
361
+ new.thread_root_id := a #>> '{ext,threadRoot}';
362
+ new.modified := (a ->> 'modified')::timestamptz;
363
+ new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
364
+
365
+ if tg_op = 'UPDATE' then
366
+ if new.id is distinct from old.id
367
+ or new.scope is distinct from old.scope
368
+ or new.document is distinct from old.document
369
+ or new.thread_root_id is distinct from old.thread_root_id
370
+ then
371
+ raise exception 'id, scope, target.source and threadRoot cannot change'
372
+ using errcode = '23514';
373
+ end if;
374
+ end if;
375
+
376
+ if new.thread_root_id is not null then
377
+ perform contexthive_private.check_thread_root(
378
+ new.thread_root_id, new.scope, new.document, new.audience
379
+ );
380
+ end if;
381
+ return new;
382
+ end
383
+ $$;
384
+
385
+ revoke all on function contexthive_private.prepare_annotation() from public;
386
+
387
+ create trigger contexthive_annotations_prepare
388
+ before insert or update on public.contexthive_annotations
389
+ for each row execute function contexthive_private.prepare_annotation();
390
+
391
+ -- ---------------------------------------------------------------------------
392
+ -- Access
393
+ -- ---------------------------------------------------------------------------
394
+
395
+ alter table public.contexthive_annotations enable row level security;
396
+
397
+ revoke all on table public.contexthive_annotations from anon, authenticated;
398
+ grant select on table public.contexthive_annotations to anon, authenticated;
399
+ grant insert (annotation), update (annotation), delete
400
+ on table public.contexthive_annotations to authenticated;
401
+
402
+ create policy contexthive_annotations_read on public.contexthive_annotations
403
+ for select to anon, authenticated
404
+ using (
405
+ creator_id = (select auth.uid())
406
+ or (
407
+ audience = 'public'
408
+ and contexthive_private.public_readable(scope)
409
+ and (thread_root_id is null or contexthive_private.root_readable(thread_root_id))
410
+ )
411
+ );
412
+
413
+ create policy contexthive_annotations_create on public.contexthive_annotations
414
+ for insert to authenticated
415
+ with check (
416
+ creator_id = (select auth.uid()) and contexthive_private.scope_writable(scope)
417
+ );
418
+
419
+ create policy contexthive_annotations_update on public.contexthive_annotations
420
+ for update to authenticated
421
+ using (creator_id = (select auth.uid()))
422
+ with check (
423
+ creator_id = (select auth.uid()) and contexthive_private.scope_writable(scope)
424
+ );
425
+
426
+ create policy contexthive_annotations_delete on public.contexthive_annotations
427
+ for delete to authenticated
428
+ using (creator_id = (select auth.uid()));
429
+
430
+ -- ---------------------------------------------------------------------------
431
+ -- Realtime: `subscribe` listens to changes on this table. Realtime applies the
432
+ -- read policy above to inserts and updates before sending them.
433
+ -- ---------------------------------------------------------------------------
434
+
435
+ do $$
436
+ begin
437
+ if exists (
438
+ select 1 from pg_publication where pubname = 'supabase_realtime' and not puballtables
439
+ ) and not exists (
440
+ select 1 from pg_publication_tables
441
+ where pubname = 'supabase_realtime'
442
+ and schemaname = 'public' and tablename = 'contexthive_annotations'
443
+ ) then
444
+ alter publication supabase_realtime add table public.contexthive_annotations;
445
+ end if;
446
+ end
447
+ $$;
448
+ $acornly_migration$;
449
+ insert into acornly_meta.migrations (version, name) values ('20261005090000', 'contexthive_annotations');
450
+ end if;
451
+ end
452
+ $acornly_install$;
453
+
454
+ -- 20261005130000_contexthive_threads.sql
455
+ do $acornly_install$
456
+ begin
457
+ if not exists (select 1 from acornly_meta.migrations where version = '20261005130000') then
458
+ raise notice 'acornly: applying %', '20261005130000_contexthive_threads.sql';
459
+ execute $acornly_migration$
460
+ -- Reply threads and edit history for contexthive-adapter-supabase (#87).
461
+ --
462
+ -- Context on context (replies, tags, reactions, highlights on a note) follows the
463
+ -- tree rules in contexthive-core's thread.ts:
464
+ --
465
+ -- * A child's `target.source` is its parent's id as an IRI
466
+ -- (`urn:contexthive:annotation:<encodeURIComponent(id)>`, the scheme matched
467
+ -- case-insensitively). The trigger decodes it into `parent_id`. Children
468
+ -- nest to any depth and carry `ext.threadRoot` (the note on the page) and
469
+ -- `ext.depth`.
470
+ -- * A new child is checked against its stored parent in the same statement,
471
+ -- with the parent row locked: the parent exists and the replier can read it,
472
+ -- is not a deleted placeholder, and the child has its scope, thread root,
473
+ -- the next depth, an audience no wider (private under anything, public only
474
+ -- under public) and `ext.parentRev` exactly when it quotes the parent.
475
+ -- * Others read a child only if they can read every note above it, so
476
+ -- narrowing any note hides the replies below it (not only narrowing a root).
477
+ -- * Plain writes keep the tree: an update never moves a note in its thread,
478
+ -- never changes `ext.deleted`, never touches a placeholder, never changes the
479
+ -- author of a note with children, and never widens a child past its parent.
480
+ -- A plain delete refuses children and notes with children.
481
+ -- * Deleting goes through `contexthive_delete_node`, which runs as the owner:
482
+ -- a note with any child (even one the caller cannot see) becomes a
483
+ -- placeholder, and one without is removed along with each placeholder above
484
+ -- it left with no children, whoever wrote them.
485
+ -- * `contexthive_versions` keeps earlier versions of edited notes, readable
486
+ -- only by the note's author. Removing a note or turning it into a
487
+ -- placeholder drops its versions.
488
+ --
489
+ -- Errors the adapter maps: CH001 (a tree rule, ThreadError), CH002 (an
490
+ -- audience wider than the parent's, AudienceError), CH003 (a stale rev,
491
+ -- ConflictError, with the stored rev as the detail) and P0002 (missing).
492
+
493
+ -- ---------------------------------------------------------------------------
494
+ -- Columns
495
+ -- ---------------------------------------------------------------------------
496
+
497
+ alter table public.contexthive_annotations add column parent_id text;
498
+
499
+ create index contexthive_annotations_parent_idx
500
+ on public.contexthive_annotations (parent_id) where parent_id is not null;
501
+
502
+ -- ---------------------------------------------------------------------------
503
+ -- Helpers
504
+ -- ---------------------------------------------------------------------------
505
+
506
+ -- The parent id a target names, or null for a note on a page. Mirrors core's
507
+ -- `parentIdOf`: percent-escapes are decoded as UTF-8, and a badly escaped or
508
+ -- empty id is refused.
509
+ create function contexthive_private.parent_id_of(source text)
510
+ returns text
511
+ language plpgsql
512
+ stable
513
+ set search_path = ''
514
+ as $$
515
+ declare
516
+ prefix constant text := 'urn:contexthive:annotation:';
517
+ encoded text;
518
+ decoded text;
519
+ begin
520
+ if lower(left(source, length(prefix))) <> prefix then
521
+ return null;
522
+ end if;
523
+ encoded := substr(source, length(prefix) + 1);
524
+ if encoded !~ '^([^%]|%[0-9A-Fa-f]{2})*$' then
525
+ raise exception 'target % is badly escaped', source using errcode = 'CH001';
526
+ end if;
527
+ begin
528
+ select convert_from(
529
+ coalesce(
530
+ string_agg(
531
+ case when m[1] is not null
532
+ then decode(substr(m[1], 2), 'hex')
533
+ else convert_to(m[2], 'UTF8')
534
+ end,
535
+ ''::bytea order by n
536
+ ),
537
+ ''::bytea
538
+ ),
539
+ 'UTF8'
540
+ )
541
+ into decoded
542
+ from regexp_matches(encoded, '(%[0-9A-Fa-f]{2})|([^%]+)', 'g') with ordinality as t (m, n);
543
+ exception when character_not_in_repertoire or untranslatable_character then
544
+ raise exception 'target % is badly escaped', source using errcode = 'CH001';
545
+ end;
546
+ if decoded = '' then
547
+ raise exception 'target % names an empty id', source using errcode = 'CH001';
548
+ end if;
549
+ return decoded;
550
+ end
551
+ $$;
552
+
553
+ -- Whether the caller may read every note from `first` up to its thread root:
554
+ -- each is the caller's own, or public in a scope they may read. False when one
555
+ -- is missing (its author was deleted), so orphans are hidden too. Reads as the
556
+ -- owner, so it never recurses into the read policy.
557
+ create function contexthive_private.ancestors_readable(first text)
558
+ returns boolean
559
+ language sql
560
+ stable
561
+ security definer
562
+ set search_path = ''
563
+ as $$
564
+ with recursive chain (id, parent_id, readable, depth, last_parent) as (
565
+ select a.id,
566
+ a.parent_id,
567
+ coalesce(
568
+ a.creator_id = auth.uid()
569
+ or (a.audience = 'public' and contexthive_private.public_readable(a.scope)),
570
+ false
571
+ ),
572
+ 1,
573
+ a.parent_id
574
+ from public.contexthive_annotations a
575
+ where a.id = first
576
+ union all
577
+ select a.id,
578
+ a.parent_id,
579
+ coalesce(
580
+ a.creator_id = auth.uid()
581
+ or (a.audience = 'public' and contexthive_private.public_readable(a.scope)),
582
+ false
583
+ ),
584
+ c.depth + 1,
585
+ a.parent_id
586
+ from public.contexthive_annotations a
587
+ join chain c on a.id = c.parent_id
588
+ -- Stop at the first unreadable note. A cycle cannot be written (children
589
+ -- need an existing parent), but stay bounded anyway.
590
+ where c.readable and c.depth < 10000
591
+ )
592
+ select coalesce(bool_and(readable), false)
593
+ -- The top of the chain must be a note on a page, not a dangling parent.
594
+ and bool_or(parent_id is null)
595
+ from chain
596
+ $$;
597
+
598
+ -- The read policy, shared by the policy itself and the checked functions that
599
+ -- run as the owner. A child is hidden from others unless every note above it is
600
+ -- readable to them, so narrowing a note hides the replies below it.
601
+ create function contexthive_private.can_read(r public.contexthive_annotations)
602
+ returns boolean
603
+ language sql
604
+ stable
605
+ security definer
606
+ set search_path = ''
607
+ as $$
608
+ select r.creator_id = auth.uid()
609
+ or (
610
+ r.audience = 'public'
611
+ and contexthive_private.public_readable(r.scope)
612
+ and (r.thread_root_id is null or contexthive_private.root_readable(r.thread_root_id))
613
+ and (r.parent_id is null or contexthive_private.ancestors_readable(r.parent_id))
614
+ )
615
+ $$;
616
+
617
+ -- Whether any stored note targets `note_id`, seen by the owner: children the
618
+ -- caller cannot read count too. A thread root also keeps its place for any
619
+ -- note in its thread, including replies from before threads nested (they name
620
+ -- only their root) and replies whose parent went with its deleted author, since
621
+ -- removing the root would take them with it.
622
+ create function contexthive_private.has_children(note_id text)
623
+ returns boolean
624
+ language sql
625
+ stable
626
+ security definer
627
+ set search_path = ''
628
+ as $$
629
+ select exists (
630
+ select 1 from public.contexthive_annotations c
631
+ where c.parent_id = note_id or c.thread_root_id = note_id
632
+ )
633
+ $$;
634
+
635
+ -- Whether a note is a deleted placeholder.
636
+ create function contexthive_private.is_deleted(a jsonb)
637
+ returns boolean
638
+ language sql
639
+ immutable
640
+ set search_path = ''
641
+ as $$
642
+ select coalesce(a #> '{ext,deleted}' = 'true'::jsonb, false)
643
+ $$;
644
+
645
+ -- Whether `child` reaches no one who cannot read `parent`. Only private and
646
+ -- public exist here: private fits under anything, public only under public.
647
+ create function contexthive_private.audience_within(child text, parent text)
648
+ returns boolean
649
+ language sql
650
+ immutable
651
+ set search_path = ''
652
+ as $$
653
+ select child = 'private' or parent = 'public'
654
+ $$;
655
+
656
+ -- Raises unless the new child `a` (in scope `child_scope`, audience
657
+ -- `child_audience`, under thread root `child_root`) fits under its stored
658
+ -- parent `parent`, which it locks until the transaction ends so a delete
659
+ -- cannot miss the child. Reads the parent as the owner but reports one the
660
+ -- caller cannot read exactly like a missing one; a trusted caller may add
661
+ -- children under any note.
662
+ create function contexthive_private.check_child(
663
+ parent text,
664
+ a jsonb,
665
+ child_scope text,
666
+ child_root text,
667
+ child_audience text
668
+ )
669
+ returns void
670
+ language plpgsql
671
+ volatile
672
+ security definer
673
+ set search_path = ''
674
+ as $$
675
+ declare
676
+ p public.contexthive_annotations;
677
+ parent_rev jsonb := a #> '{ext,parentRev}';
678
+ selectors jsonb := a #> '{target,selector}';
679
+ quoted boolean;
680
+ begin
681
+ select * into p from public.contexthive_annotations r where r.id = parent for share;
682
+ if not found
683
+ or not (contexthive_private.trusted_caller() or contexthive_private.can_read(p))
684
+ then
685
+ raise exception 'annotation % not found', parent using errcode = 'P0002';
686
+ end if;
687
+ if contexthive_private.is_deleted(p.annotation) then
688
+ raise exception 'annotation % is deleted', parent using errcode = 'CH001';
689
+ end if;
690
+ -- A reply from before threads nested shows only on its page.
691
+ if p.parent_id is null and p.thread_root_id is not null then
692
+ raise exception 'annotation % is a reply from before threads nested; reply to its root',
693
+ parent using errcode = 'CH001';
694
+ end if;
695
+ if p.scope <> child_scope then
696
+ raise exception 'annotation % is not in its parent''s scope', a ->> 'id' using errcode = 'CH001';
697
+ end if;
698
+ if child_root is distinct from coalesce(p.thread_root_id, p.id) then
699
+ raise exception 'annotation % is not in its parent''s thread', a ->> 'id'
700
+ using errcode = 'CH001';
701
+ end if;
702
+ if (a #>> '{ext,depth}')::numeric
703
+ is distinct from coalesce((p.annotation #>> '{ext,depth}')::numeric, 0) + 1
704
+ then
705
+ raise exception 'annotation % has the wrong depth', a ->> 'id' using errcode = 'CH001';
706
+ end if;
707
+ if not contexthive_private.audience_within(child_audience, p.audience) then
708
+ raise exception 'annotation % has a wider audience than its parent', a ->> 'id'
709
+ using errcode = 'CH002';
710
+ end if;
711
+ quoted := jsonb_typeof(selectors) = 'array' and exists (
712
+ select 1 from jsonb_array_elements(selectors) s where s ->> 'type' = 'TextQuoteSelector'
713
+ );
714
+ if quoted is distinct from (parent_rev is not null)
715
+ or (parent_rev is not null and ((parent_rev #>> '{}')::numeric < 1
716
+ or (parent_rev #>> '{}')::numeric > p.rev))
717
+ then
718
+ raise exception 'annotation % must record the parent''s rev exactly when it quotes the parent',
719
+ a ->> 'id' using errcode = 'CH001';
720
+ end if;
721
+ end
722
+ $$;
723
+
724
+ -- The audience of `note_id`, read as the owner and locked until the
725
+ -- transaction ends, or 'public' when it is gone (nothing left to protect).
726
+ create function contexthive_private.locked_audience(note_id text)
727
+ returns text
728
+ language sql
729
+ volatile
730
+ security definer
731
+ set search_path = ''
732
+ as $$
733
+ select coalesce(
734
+ (select a.audience from public.contexthive_annotations a where a.id = note_id for share),
735
+ 'public'
736
+ )
737
+ $$;
738
+
739
+ -- Whether the current statement runs inside `contexthive_delete_node`, which
740
+ -- writes placeholders and removes children. The flag alone is not enough, as
741
+ -- any SQL session can set a custom setting: the statement must also run as the
742
+ -- table's owner, which only that security-definer function does for a client.
743
+ create function contexthive_private.in_delete_node()
744
+ returns boolean
745
+ language sql
746
+ stable
747
+ set search_path = ''
748
+ as $$
749
+ select coalesce(current_setting('contexthive.delete_node', true), '') = 'on'
750
+ and current_user = (
751
+ select pg_catalog.pg_get_userbyid(c.relowner)
752
+ from pg_catalog.pg_class c
753
+ where c.oid = 'public.contexthive_annotations'::regclass
754
+ )
755
+ $$;
756
+
757
+ -- The checks on the root replaced by `check_child`.
758
+ drop function contexthive_private.check_thread_root(text, text, text, text);
759
+
760
+ revoke all on all functions in schema contexthive_private from public;
761
+ grant execute on function
762
+ contexthive_private.signed_in(),
763
+ contexthive_private.trusted_caller(),
764
+ contexthive_private.public_readable(text),
765
+ contexthive_private.root_readable(text),
766
+ contexthive_private.scope_writable(text),
767
+ contexthive_private.parent_id_of(text),
768
+ contexthive_private.ancestors_readable(text),
769
+ contexthive_private.can_read(public.contexthive_annotations),
770
+ contexthive_private.has_children(text),
771
+ contexthive_private.is_deleted(jsonb),
772
+ contexthive_private.audience_within(text, text),
773
+ contexthive_private.check_child(text, jsonb, text, text, text),
774
+ contexthive_private.locked_audience(text),
775
+ contexthive_private.in_delete_node()
776
+ to anon, authenticated, service_role;
777
+
778
+ -- ---------------------------------------------------------------------------
779
+ -- Write triggers
780
+ -- ---------------------------------------------------------------------------
781
+
782
+ create or replace function contexthive_private.prepare_annotation()
783
+ returns trigger
784
+ language plpgsql
785
+ set search_path = ''
786
+ as $$
787
+ declare
788
+ a jsonb := new.annotation;
789
+ audience_kind text := a #>> '{ext,audience,kind}';
790
+ -- The site's own server code writing on a user's behalf.
791
+ trusted boolean := contexthive_private.trusted_caller();
792
+ begin
793
+ if jsonb_typeof(a -> 'id') is distinct from 'string'
794
+ or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
795
+ or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
796
+ or jsonb_typeof(a -> 'modified') is distinct from 'string'
797
+ -- ISO 8601 only, so no `infinity`, `now` or other special values.
798
+ or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
799
+ or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
800
+ then
801
+ raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
802
+ using errcode = '23514';
803
+ end if;
804
+ if coalesce(jsonb_typeof(a #> '{ext,depth}'), 'null') not in ('number', 'null')
805
+ or coalesce(jsonb_typeof(a #> '{ext,parentRev}'), 'null') not in ('number', 'null')
806
+ or coalesce(jsonb_typeof(a #> '{ext,deleted}'), 'null') not in ('boolean', 'null')
807
+ then
808
+ raise exception 'ext.depth and ext.parentRev must be numbers and ext.deleted a boolean'
809
+ using errcode = '23514';
810
+ end if;
811
+
812
+ if tg_op = 'INSERT' then
813
+ if not trusted then
814
+ -- Clients cannot write this column; the author is always the caller.
815
+ new.creator_id := auth.uid();
816
+ if new.creator_id is null then
817
+ raise exception 'sign in to write notes' using errcode = '42501';
818
+ end if;
819
+ elsif new.creator_id is null then
820
+ raise exception 'creator_id is required' using errcode = '23502';
821
+ end if;
822
+ new.rev := 1;
823
+ else
824
+ new.creator_id := old.creator_id;
825
+ new.rev := old.rev + 1;
826
+ end if;
827
+
828
+ if audience_kind is null or audience_kind not in ('private', 'public') then
829
+ raise exception 'audience "%" is not supported here; use private or public', audience_kind
830
+ using errcode = '23514';
831
+ end if;
832
+
833
+ new.id := a ->> 'id';
834
+ new.scope := a #>> '{ext,scope}';
835
+ new.document := a #>> '{target,source}';
836
+ new.audience := audience_kind;
837
+ new.thread_root_id := a #>> '{ext,threadRoot}';
838
+ -- The document never changes, so neither does the parent; notes stored
839
+ -- before this migration keep theirs (none) when edited.
840
+ new.parent_id := case
841
+ when tg_op = 'UPDATE' then old.parent_id
842
+ else contexthive_private.parent_id_of(new.document)
843
+ end;
844
+ new.modified := (a ->> 'modified')::timestamptz;
845
+ new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
846
+
847
+ if tg_op = 'INSERT' then
848
+ if contexthive_private.is_deleted(a) then
849
+ raise exception 'a new annotation cannot be deleted' using errcode = 'CH001';
850
+ end if;
851
+ if new.parent_id is null then
852
+ if new.thread_root_id is not null
853
+ or a #> '{ext,depth}' is not null
854
+ or a #> '{ext,parentRev}' is not null
855
+ then
856
+ raise exception 'annotation % has thread fields but targets a page', new.id
857
+ using errcode = 'CH001';
858
+ end if;
859
+ else
860
+ perform contexthive_private.check_child(
861
+ new.parent_id, a, new.scope, new.thread_root_id, new.audience
862
+ );
863
+ end if;
864
+ return new;
865
+ end if;
866
+
867
+ if new.id is distinct from old.id then
868
+ raise exception 'id cannot change' using errcode = '23514';
869
+ end if;
870
+ -- `contexthive_delete_node` writing a placeholder.
871
+ if contexthive_private.in_delete_node() then
872
+ return new;
873
+ end if;
874
+ if contexthive_private.is_deleted(a) is distinct from contexthive_private.is_deleted(old.annotation) then
875
+ raise exception 'annotation % is deleted or restored only by deleteNode', new.id
876
+ using errcode = 'CH001';
877
+ end if;
878
+ if contexthive_private.is_deleted(old.annotation) then
879
+ raise exception 'annotation % is deleted', new.id using errcode = 'CH001';
880
+ end if;
881
+ if new.scope is distinct from old.scope
882
+ or new.document is distinct from old.document
883
+ or new.thread_root_id is distinct from old.thread_root_id
884
+ or a #> '{ext,depth}' is distinct from old.annotation #> '{ext,depth}'
885
+ or a #> '{ext,parentRev}' is distinct from old.annotation #> '{ext,parentRev}'
886
+ or (old.parent_id is not null and a -> 'target' is distinct from old.annotation -> 'target')
887
+ then
888
+ raise exception 'annotation % cannot move: id, scope, target.source and threadRoot cannot change',
889
+ new.id using errcode = 'CH001';
890
+ end if;
891
+ if a #> '{creator,id}' is distinct from old.annotation #> '{creator,id}'
892
+ and contexthive_private.has_children(new.id)
893
+ then
894
+ raise exception 'annotation % has replies; its author stays', new.id using errcode = 'CH001';
895
+ end if;
896
+ -- An unchanged or narrower audience is fine even if the parent was narrowed
897
+ -- meanwhile. A wider one is checked against the parent, locked so a
898
+ -- concurrent narrowing cannot slip past; a reply from before threads nested
899
+ -- is checked against its root.
900
+ if new.thread_root_id is not null
901
+ and not contexthive_private.audience_within(new.audience, old.audience)
902
+ and not contexthive_private.audience_within(
903
+ new.audience,
904
+ contexthive_private.locked_audience(coalesce(old.parent_id, old.thread_root_id))
905
+ )
906
+ then
907
+ raise exception 'annotation % cannot widen its parent''s audience', new.id
908
+ using errcode = 'CH002';
909
+ end if;
910
+ return new;
911
+ end
912
+ $$;
913
+
914
+ revoke all on function contexthive_private.prepare_annotation() from public;
915
+
916
+ -- Plain deletes keep the tree; `contexthive_delete_node` and cascades (a
917
+ -- deleted user's notes) are let through.
918
+ create function contexthive_private.check_delete()
919
+ returns trigger
920
+ language plpgsql
921
+ set search_path = ''
922
+ as $$
923
+ begin
924
+ if contexthive_private.in_delete_node() or pg_trigger_depth() > 1 then
925
+ return old;
926
+ end if;
927
+ if old.parent_id is not null then
928
+ raise exception 'annotation % is context on context; use threads.deleteNode', old.id
929
+ using errcode = 'CH001';
930
+ end if;
931
+ if contexthive_private.has_children(old.id) then
932
+ raise exception 'annotation % has children; use threads.deleteNode', old.id
933
+ using errcode = 'CH001';
934
+ end if;
935
+ return old;
936
+ end
937
+ $$;
938
+
939
+ revoke all on function contexthive_private.check_delete() from public;
940
+
941
+ create trigger contexthive_annotations_check_delete
942
+ before delete on public.contexthive_annotations
943
+ for each row execute function contexthive_private.check_delete();
944
+
945
+ -- ---------------------------------------------------------------------------
946
+ -- Reading: replies are hidden below any note the reader cannot read
947
+ -- ---------------------------------------------------------------------------
948
+
949
+ drop policy contexthive_annotations_read on public.contexthive_annotations;
950
+
951
+ create policy contexthive_annotations_read on public.contexthive_annotations
952
+ for select to anon, authenticated
953
+ using (
954
+ creator_id = (select auth.uid())
955
+ or (
956
+ audience = 'public'
957
+ and contexthive_private.public_readable(scope)
958
+ and (thread_root_id is null or contexthive_private.root_readable(thread_root_id))
959
+ and (parent_id is null or contexthive_private.ancestors_readable(parent_id))
960
+ )
961
+ );
962
+
963
+ -- No `parent_id` backfill: before this migration a reply had to share its
964
+ -- root's page, so no stored row is context on context.
965
+
966
+ -- ---------------------------------------------------------------------------
967
+ -- Edit history
968
+ -- ---------------------------------------------------------------------------
969
+
970
+ create table public.contexthive_versions (
971
+ note_id text not null references public.contexthive_annotations (id) on delete cascade,
972
+ rev integer not null check (rev >= 1),
973
+ -- The note's author, set by the trigger.
974
+ creator_id uuid not null references auth.users (id) on delete cascade,
975
+ annotation jsonb not null check (
976
+ jsonb_typeof(annotation) = 'object' and octet_length(annotation::text) <= 65536
977
+ ),
978
+ primary key (note_id, rev)
979
+ );
980
+
981
+ create index contexthive_versions_creator_idx on public.contexthive_versions (creator_id);
982
+
983
+ create function contexthive_private.prepare_version()
984
+ returns trigger
985
+ language plpgsql
986
+ set search_path = ''
987
+ as $$
988
+ declare
989
+ a jsonb := new.annotation;
990
+ author uuid;
991
+ begin
992
+ if jsonb_typeof(a -> 'id') is distinct from 'string'
993
+ or jsonb_typeof(a #> '{ext,rev}') is distinct from 'number'
994
+ or (a #>> '{ext,rev}') !~ '^[1-9][0-9]{0,8}$'
995
+ then
996
+ raise exception 'a version needs a string id and a positive integer ext.rev'
997
+ using errcode = '23514';
998
+ end if;
999
+ new.note_id := a ->> 'id';
1000
+ new.rev := (a #>> '{ext,rev}')::integer;
1001
+ if tg_op = 'UPDATE' and (new.note_id <> old.note_id or new.rev <> old.rev) then
1002
+ raise exception 'a saved version keeps its id and rev' using errcode = '23514';
1003
+ end if;
1004
+ -- Read as the caller: a client finds only notes it can read.
1005
+ select n.creator_id into author from public.contexthive_annotations n where n.id = new.note_id;
1006
+ if not found then
1007
+ raise exception 'annotation % not found', new.note_id using errcode = 'P0002';
1008
+ end if;
1009
+ if not contexthive_private.trusted_caller() and author is distinct from auth.uid() then
1010
+ raise exception 'only the author keeps versions of annotation %', new.note_id
1011
+ using errcode = '42501';
1012
+ end if;
1013
+ new.creator_id := author;
1014
+ return new;
1015
+ end
1016
+ $$;
1017
+
1018
+ revoke all on function contexthive_private.prepare_version() from public;
1019
+
1020
+ create trigger contexthive_versions_prepare
1021
+ before insert or update on public.contexthive_versions
1022
+ for each row execute function contexthive_private.prepare_version();
1023
+
1024
+ alter table public.contexthive_versions enable row level security;
1025
+
1026
+ revoke all on table public.contexthive_versions from anon, authenticated;
1027
+ grant select, delete on table public.contexthive_versions to authenticated;
1028
+ -- Visitors read none (no policy admits them), but get an empty list, not an error.
1029
+ grant select on table public.contexthive_versions to anon;
1030
+ grant insert (annotation), update (annotation) on table public.contexthive_versions to authenticated;
1031
+
1032
+ create policy contexthive_versions_read on public.contexthive_versions
1033
+ for select to authenticated
1034
+ using (creator_id = (select auth.uid()));
1035
+
1036
+ create policy contexthive_versions_create on public.contexthive_versions
1037
+ for insert to authenticated
1038
+ with check (creator_id = (select auth.uid()));
1039
+
1040
+ create policy contexthive_versions_update on public.contexthive_versions
1041
+ for update to authenticated
1042
+ using (creator_id = (select auth.uid()))
1043
+ with check (creator_id = (select auth.uid()));
1044
+
1045
+ create policy contexthive_versions_delete on public.contexthive_versions
1046
+ for delete to authenticated
1047
+ using (creator_id = (select auth.uid()));
1048
+
1049
+ -- ---------------------------------------------------------------------------
1050
+ -- Thread functions the adapter calls
1051
+ -- ---------------------------------------------------------------------------
1052
+
1053
+ -- The ids in a thread the caller may read: the read policy's rules worked out
1054
+ -- top-down in one pass, seeded with the root and its replies from before
1055
+ -- threads nested and then walking children through the parent index, so the
1056
+ -- cost is linear in the thread's size whatever its shape. The caller's own
1057
+ -- notes are always in, and everything for the site's server. Empty unless
1058
+ -- `root_id` is a note on a page in `thread_scope`.
1059
+ create function contexthive_private.thread_visible(root_id text, thread_scope text)
1060
+ returns setof text
1061
+ language sql
1062
+ stable
1063
+ security definer
1064
+ set search_path = ''
1065
+ as $$
1066
+ with recursive
1067
+ me (uid, trusted, scope_readable) as materialized (
1068
+ select auth.uid(),
1069
+ contexthive_private.trusted_caller(),
1070
+ contexthive_private.public_readable(thread_scope)
1071
+ ),
1072
+ tree (id, path_readable, depth) as (
1073
+ select a.id,
1074
+ coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false),
1075
+ 0
1076
+ from public.contexthive_annotations a, me
1077
+ where a.id = root_id
1078
+ and a.scope = thread_scope
1079
+ and a.parent_id is null
1080
+ and a.thread_root_id is null
1081
+ union all
1082
+ -- Replies from before threads nested target the page and name the root.
1083
+ select c.id,
1084
+ r.path_readable
1085
+ and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
1086
+ 1
1087
+ from public.contexthive_annotations a
1088
+ join public.contexthive_annotations c
1089
+ on c.thread_root_id = a.id and c.parent_id is null and c.scope = thread_scope
1090
+ cross join me
1091
+ cross join lateral (
1092
+ select coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false)
1093
+ ) r (path_readable)
1094
+ where a.id = root_id and a.scope = thread_scope and a.thread_root_id is null
1095
+ union all
1096
+ select c.id,
1097
+ t.path_readable
1098
+ and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
1099
+ t.depth + 1
1100
+ from tree t
1101
+ join public.contexthive_annotations c on c.parent_id = t.id
1102
+ cross join me
1103
+ where c.scope = thread_scope and t.depth < 10000
1104
+ )
1105
+ select t.id from tree t, me where t.path_readable or me.trusted
1106
+ union
1107
+ -- The caller's own notes, and everything for the site's server, even below a
1108
+ -- parent that is gone.
1109
+ select a.id
1110
+ from public.contexthive_annotations a, me
1111
+ where a.scope = thread_scope
1112
+ and a.thread_root_id = root_id
1113
+ and (a.creator_id = me.uid or me.trusted)
1114
+ -- Only a real root starts a thread.
1115
+ and exists (select 1 from tree t where t.depth = 0)
1116
+ $$;
1117
+
1118
+ -- Only the functions below call it, as the owner.
1119
+ revoke all on function contexthive_private.thread_visible(text, text) from public;
1120
+
1121
+ -- A thread's root and every node below it in one scope, as the caller may read
1122
+ -- them, as one JSON array of annotations ordered by `modified` and id. One call
1123
+ -- returns everything, as paging would redo the walk for each page.
1124
+ create function public.contexthive_list_thread(root_id text, thread_scope text)
1125
+ returns jsonb
1126
+ language sql
1127
+ stable
1128
+ security definer
1129
+ set search_path = ''
1130
+ as $$
1131
+ select coalesce(jsonb_agg(a.annotation order by a.modified, a.id), '[]'::jsonb)
1132
+ from contexthive_private.thread_visible(root_id, thread_scope) v (id)
1133
+ join public.contexthive_annotations a on a.id = v.id
1134
+ $$;
1135
+
1136
+ -- The number of direct children of each id that the caller may read. Replies
1137
+ -- from before threads nested are left out, as `assembleThread` does not place
1138
+ -- them. Ids with none are left out.
1139
+ -- Visibility comes from one top-down walk per thread involved.
1140
+ create function public.contexthive_count_children(ids text[])
1141
+ returns table (id text, children bigint)
1142
+ language sql
1143
+ stable
1144
+ security definer
1145
+ set search_path = ''
1146
+ as $$
1147
+ with children (parent, child, thread_root_id, scope) as materialized (
1148
+ select c.parent_id, c.id, c.thread_root_id, c.scope
1149
+ from public.contexthive_annotations c
1150
+ where c.parent_id = any (ids)
1151
+ ),
1152
+ visible (id) as materialized (
1153
+ select v.id
1154
+ from (select distinct thread_root_id, scope from children) t
1155
+ cross join lateral contexthive_private.thread_visible(t.thread_root_id, t.scope) v (id)
1156
+ )
1157
+ select c.parent, count(*)
1158
+ from children c
1159
+ join visible v on v.id = c.child
1160
+ group by c.parent
1161
+ $$;
1162
+
1163
+ -- Deletes `note_id` at `expected_rev` for its author (or the site's server).
1164
+ -- A note with any child becomes a placeholder at the next rev, modified at
1165
+ -- `deleted_at`: no body, author name or edited mark, `ext.deleted` true, and
1166
+ -- its versions dropped. One without children is removed, and so is each
1167
+ -- placeholder above it left with no children. Returns
1168
+ -- `{"placeholder": <annotation>}` or `{"removed": [{id, scope, document}, ...]}`,
1169
+ -- the note first.
1170
+ create function public.contexthive_delete_node(
1171
+ note_id text,
1172
+ expected_rev integer,
1173
+ deleted_at text
1174
+ )
1175
+ returns jsonb
1176
+ language plpgsql
1177
+ volatile
1178
+ security definer
1179
+ set search_path = ''
1180
+ as $$
1181
+ declare
1182
+ target public.contexthive_annotations;
1183
+ node public.contexthive_annotations;
1184
+ above public.contexthive_annotations;
1185
+ placeholder jsonb;
1186
+ removed jsonb := '[]'::jsonb;
1187
+ trusted boolean := contexthive_private.trusted_caller();
1188
+ begin
1189
+ select * into target from public.contexthive_annotations a where a.id = note_id for update;
1190
+ if not found or not (trusted or contexthive_private.can_read(target)) then
1191
+ raise exception 'annotation % not found', note_id using errcode = 'P0002';
1192
+ end if;
1193
+ if not trusted and target.creator_id is distinct from auth.uid() then
1194
+ raise exception 'Not allowed to change annotation %', note_id using errcode = '42501';
1195
+ end if;
1196
+ if target.rev <> expected_rev then
1197
+ raise exception 'annotation % is at rev %, expected %', note_id, target.rev, expected_rev
1198
+ using errcode = 'CH003', detail = target.rev::text;
1199
+ end if;
1200
+
1201
+ perform set_config('contexthive.delete_node', 'on', true);
1202
+
1203
+ if contexthive_private.has_children(note_id) then
1204
+ placeholder := (target.annotation - 'body') || jsonb_build_object('modified', deleted_at);
1205
+ placeholder := jsonb_set(
1206
+ placeholder,
1207
+ '{ext}',
1208
+ ((target.annotation -> 'ext') - 'edited') || '{"deleted": true}'::jsonb
1209
+ );
1210
+ if jsonb_typeof(target.annotation -> 'creator') = 'object' then
1211
+ placeholder := jsonb_set(
1212
+ placeholder,
1213
+ '{creator}',
1214
+ jsonb_strip_nulls(jsonb_build_object('id', target.annotation #> '{creator,id}'))
1215
+ );
1216
+ end if;
1217
+ update public.contexthive_annotations a
1218
+ set annotation = placeholder
1219
+ where a.id = note_id
1220
+ returning a.annotation into placeholder;
1221
+ delete from public.contexthive_versions v where v.note_id = target.id;
1222
+ perform set_config('contexthive.delete_node', '', true);
1223
+ return jsonb_build_object('placeholder', placeholder);
1224
+ end if;
1225
+
1226
+ -- Remove it, then each placeholder above it left with no children. Each
1227
+ -- parent is locked before its children are counted, so a child landing
1228
+ -- meanwhile either waits for this delete or is seen.
1229
+ node := target;
1230
+ loop
1231
+ delete from public.contexthive_annotations a where a.id = node.id;
1232
+ removed := removed || jsonb_build_object(
1233
+ 'id', node.id, 'scope', node.scope, 'document', node.document
1234
+ );
1235
+ exit when coalesce(node.parent_id, node.thread_root_id) is null;
1236
+ select * into above from public.contexthive_annotations a
1237
+ where a.id = coalesce(node.parent_id, node.thread_root_id)
1238
+ for update;
1239
+ -- An orphan's parent went with its author: its root may still be waiting.
1240
+ if not found and node.parent_id is not null and node.thread_root_id is not null then
1241
+ select * into above from public.contexthive_annotations a
1242
+ where a.id = node.thread_root_id
1243
+ for update;
1244
+ end if;
1245
+ exit when not found
1246
+ or not contexthive_private.is_deleted(above.annotation)
1247
+ or contexthive_private.has_children(above.id);
1248
+ node := above;
1249
+ end loop;
1250
+ perform set_config('contexthive.delete_node', '', true);
1251
+ return jsonb_build_object('removed', removed);
1252
+ end
1253
+ $$;
1254
+
1255
+ revoke all on function
1256
+ public.contexthive_list_thread(text, text),
1257
+ public.contexthive_count_children(text[]),
1258
+ public.contexthive_delete_node(text, integer, text)
1259
+ from public;
1260
+ grant execute on function
1261
+ public.contexthive_list_thread(text, text),
1262
+ public.contexthive_count_children(text[])
1263
+ to anon, authenticated, service_role;
1264
+ grant execute on function public.contexthive_delete_node(text, integer, text)
1265
+ to authenticated, service_role;
1266
+ $acornly_migration$;
1267
+ insert into acornly_meta.migrations (version, name) values ('20261005130000', 'contexthive_threads');
1268
+ end if;
1269
+ end
1270
+ $acornly_install$;
1271
+
1272
+ -- 20261010070000_acornly_names.sql
1273
+ do $acornly_install$
1274
+ begin
1275
+ if not exists (select 1 from acornly_meta.migrations where version = '20261010070000') then
1276
+ raise notice 'acornly: applying %', '20261010070000_acornly_names.sql';
1277
+ execute $acornly_migration$
1278
+ -- Renames everything the earlier migrations in this folder created from
1279
+ -- `contexthive` to `acornly`, as the product is now Acornly.
1280
+ --
1281
+ -- Renaming keeps each object's data, grants, row level security, policies,
1282
+ -- triggers and Realtime publication, since those follow the object. Function
1283
+ -- bodies are stored as text, though, so every function that names a renamed
1284
+ -- object is recreated below with the new names. The delete-node flag becomes
1285
+ -- `acornly.delete_node`; its reader and its writer change together here.
1286
+ --
1287
+ -- Left as they are: the `urn:contexthive:annotation:` target prefix (part of
1288
+ -- the stored notes, not of the schema) and the CH001 to CH003 error codes the
1289
+ -- adapter maps.
1290
+
1291
+ -- ---------------------------------------------------------------------------
1292
+ -- Schema and tables
1293
+ -- ---------------------------------------------------------------------------
1294
+
1295
+ alter schema contexthive_private rename to acornly_private;
1296
+
1297
+ alter table public.contexthive_scopes rename to acornly_scopes;
1298
+ alter table public.contexthive_annotations rename to acornly_annotations;
1299
+ alter table public.contexthive_versions rename to acornly_versions;
1300
+
1301
+ -- Constraints (primary keys rename their index too), then the other indexes,
1302
+ -- including the names Postgres chose itself such as `..._scope_check`.
1303
+ do $$
1304
+ declare
1305
+ c record;
1306
+ begin
1307
+ for c in
1308
+ select con.conrelid::regclass as tbl, con.conname
1309
+ from pg_catalog.pg_constraint con
1310
+ where con.conrelid in (
1311
+ 'public.acornly_scopes'::regclass,
1312
+ 'public.acornly_annotations'::regclass,
1313
+ 'public.acornly_versions'::regclass
1314
+ )
1315
+ and con.conname like 'contexthive\_%'
1316
+ loop
1317
+ execute format(
1318
+ 'alter table %s rename constraint %I to %I',
1319
+ c.tbl, c.conname, 'acornly_' || substr(c.conname, length('contexthive_') + 1)
1320
+ );
1321
+ end loop;
1322
+
1323
+ for c in
1324
+ select i.relname
1325
+ from pg_catalog.pg_index x
1326
+ join pg_catalog.pg_class i on i.oid = x.indexrelid
1327
+ where x.indrelid in (
1328
+ 'public.acornly_scopes'::regclass,
1329
+ 'public.acornly_annotations'::regclass,
1330
+ 'public.acornly_versions'::regclass
1331
+ )
1332
+ and i.relname like 'contexthive\_%'
1333
+ loop
1334
+ execute format(
1335
+ 'alter index public.%I rename to %I',
1336
+ c.relname, 'acornly_' || substr(c.relname, length('contexthive_') + 1)
1337
+ );
1338
+ end loop;
1339
+ end
1340
+ $$;
1341
+
1342
+ -- ---------------------------------------------------------------------------
1343
+ -- Triggers and policies
1344
+ -- ---------------------------------------------------------------------------
1345
+
1346
+ alter trigger contexthive_annotations_prepare on public.acornly_annotations
1347
+ rename to acornly_annotations_prepare;
1348
+ alter trigger contexthive_annotations_check_delete on public.acornly_annotations
1349
+ rename to acornly_annotations_check_delete;
1350
+ alter trigger contexthive_versions_prepare on public.acornly_versions
1351
+ rename to acornly_versions_prepare;
1352
+
1353
+ alter policy contexthive_annotations_read on public.acornly_annotations
1354
+ rename to acornly_annotations_read;
1355
+ alter policy contexthive_annotations_create on public.acornly_annotations
1356
+ rename to acornly_annotations_create;
1357
+ alter policy contexthive_annotations_update on public.acornly_annotations
1358
+ rename to acornly_annotations_update;
1359
+ alter policy contexthive_annotations_delete on public.acornly_annotations
1360
+ rename to acornly_annotations_delete;
1361
+
1362
+ alter policy contexthive_versions_read on public.acornly_versions
1363
+ rename to acornly_versions_read;
1364
+ alter policy contexthive_versions_create on public.acornly_versions
1365
+ rename to acornly_versions_create;
1366
+ alter policy contexthive_versions_update on public.acornly_versions
1367
+ rename to acornly_versions_update;
1368
+ alter policy contexthive_versions_delete on public.acornly_versions
1369
+ rename to acornly_versions_delete;
1370
+
1371
+ -- ---------------------------------------------------------------------------
1372
+ -- Functions: the adapter's entry points get the new prefix, and every function
1373
+ -- naming a renamed object is recreated. `create or replace` keeps each one's
1374
+ -- owner and grants. The helpers below name no renamed object and are left:
1375
+ -- signed_in, trusted_caller, is_deleted, audience_within and parent_id_of.
1376
+ -- ---------------------------------------------------------------------------
1377
+
1378
+ alter function public.contexthive_list_thread(text, text) rename to acornly_list_thread;
1379
+ alter function public.contexthive_count_children(text[]) rename to acornly_count_children;
1380
+ alter function public.contexthive_delete_node(text, integer, text) rename to acornly_delete_node;
1381
+
1382
+ -- Whether the caller may read public notes in `note_scope`: anyone signed in,
1383
+ -- and anonymous visitors where the site turned on `anonymous_read`.
1384
+ create or replace function acornly_private.public_readable(note_scope text)
1385
+ returns boolean
1386
+ language sql
1387
+ stable
1388
+ security definer
1389
+ set search_path = ''
1390
+ as $$
1391
+ select acornly_private.signed_in()
1392
+ or exists (
1393
+ select 1 from public.acornly_scopes s
1394
+ where s.scope = note_scope and s.anonymous_read
1395
+ )
1396
+ $$;
1397
+
1398
+ -- Whether the caller may read the thread root `root_id`. Roots have no root of
1399
+ -- their own, so this never recurses.
1400
+ create or replace function acornly_private.root_readable(root_id text)
1401
+ returns boolean
1402
+ language sql
1403
+ stable
1404
+ security definer
1405
+ set search_path = ''
1406
+ as $$
1407
+ select exists (
1408
+ select 1 from public.acornly_annotations r
1409
+ where r.id = root_id
1410
+ and (
1411
+ r.creator_id = auth.uid()
1412
+ or (r.audience = 'public' and acornly_private.public_readable(r.scope))
1413
+ )
1414
+ )
1415
+ $$;
1416
+
1417
+ -- Whether the signed-in caller may write notes in `note_scope`.
1418
+ create or replace function acornly_private.scope_writable(note_scope text)
1419
+ returns boolean
1420
+ language sql
1421
+ stable
1422
+ security definer
1423
+ set search_path = ''
1424
+ as $$
1425
+ select acornly_private.signed_in()
1426
+ and (
1427
+ note_scope = 'user:' || auth.uid()::text
1428
+ or exists (select 1 from public.acornly_scopes s where s.scope = note_scope)
1429
+ )
1430
+ $$;
1431
+
1432
+ -- Whether the caller may read every note from `first` up to its thread root:
1433
+ -- each is the caller's own, or public in a scope they may read. False when one
1434
+ -- is missing (its author was deleted), so orphans are hidden too. Reads as the
1435
+ -- owner, so it never recurses into the read policy.
1436
+ create or replace function acornly_private.ancestors_readable(first text)
1437
+ returns boolean
1438
+ language sql
1439
+ stable
1440
+ security definer
1441
+ set search_path = ''
1442
+ as $$
1443
+ with recursive chain (id, parent_id, readable, depth, last_parent) as (
1444
+ select a.id,
1445
+ a.parent_id,
1446
+ coalesce(
1447
+ a.creator_id = auth.uid()
1448
+ or (a.audience = 'public' and acornly_private.public_readable(a.scope)),
1449
+ false
1450
+ ),
1451
+ 1,
1452
+ a.parent_id
1453
+ from public.acornly_annotations a
1454
+ where a.id = first
1455
+ union all
1456
+ select a.id,
1457
+ a.parent_id,
1458
+ coalesce(
1459
+ a.creator_id = auth.uid()
1460
+ or (a.audience = 'public' and acornly_private.public_readable(a.scope)),
1461
+ false
1462
+ ),
1463
+ c.depth + 1,
1464
+ a.parent_id
1465
+ from public.acornly_annotations a
1466
+ join chain c on a.id = c.parent_id
1467
+ -- Stop at the first unreadable note. A cycle cannot be written (children
1468
+ -- need an existing parent), but stay bounded anyway.
1469
+ where c.readable and c.depth < 10000
1470
+ )
1471
+ select coalesce(bool_and(readable), false)
1472
+ -- The top of the chain must be a note on a page, not a dangling parent.
1473
+ and bool_or(parent_id is null)
1474
+ from chain
1475
+ $$;
1476
+
1477
+ -- The read policy, shared by the policy itself and the checked functions that
1478
+ -- run as the owner. A child is hidden from others unless every note above it is
1479
+ -- readable to them, so narrowing a note hides the replies below it.
1480
+ create or replace function acornly_private.can_read(r public.acornly_annotations)
1481
+ returns boolean
1482
+ language sql
1483
+ stable
1484
+ security definer
1485
+ set search_path = ''
1486
+ as $$
1487
+ select r.creator_id = auth.uid()
1488
+ or (
1489
+ r.audience = 'public'
1490
+ and acornly_private.public_readable(r.scope)
1491
+ and (r.thread_root_id is null or acornly_private.root_readable(r.thread_root_id))
1492
+ and (r.parent_id is null or acornly_private.ancestors_readable(r.parent_id))
1493
+ )
1494
+ $$;
1495
+
1496
+ -- Whether any stored note targets `note_id`, seen by the owner: children the
1497
+ -- caller cannot read count too. A thread root also keeps its place for any
1498
+ -- note in its thread, including replies from before threads nested (they name
1499
+ -- only their root) and replies whose parent went with its deleted author, since
1500
+ -- removing the root would take them with it.
1501
+ create or replace function acornly_private.has_children(note_id text)
1502
+ returns boolean
1503
+ language sql
1504
+ stable
1505
+ security definer
1506
+ set search_path = ''
1507
+ as $$
1508
+ select exists (
1509
+ select 1 from public.acornly_annotations c
1510
+ where c.parent_id = note_id or c.thread_root_id = note_id
1511
+ )
1512
+ $$;
1513
+
1514
+ -- Raises unless the new child `a` (in scope `child_scope`, audience
1515
+ -- `child_audience`, under thread root `child_root`) fits under its stored
1516
+ -- parent `parent`, which it locks until the transaction ends so a delete
1517
+ -- cannot miss the child. Reads the parent as the owner but reports one the
1518
+ -- caller cannot read exactly like a missing one; a trusted caller may add
1519
+ -- children under any note.
1520
+ create or replace function acornly_private.check_child(
1521
+ parent text,
1522
+ a jsonb,
1523
+ child_scope text,
1524
+ child_root text,
1525
+ child_audience text
1526
+ )
1527
+ returns void
1528
+ language plpgsql
1529
+ volatile
1530
+ security definer
1531
+ set search_path = ''
1532
+ as $$
1533
+ declare
1534
+ p public.acornly_annotations;
1535
+ parent_rev jsonb := a #> '{ext,parentRev}';
1536
+ selectors jsonb := a #> '{target,selector}';
1537
+ quoted boolean;
1538
+ begin
1539
+ select * into p from public.acornly_annotations r where r.id = parent for share;
1540
+ if not found
1541
+ or not (acornly_private.trusted_caller() or acornly_private.can_read(p))
1542
+ then
1543
+ raise exception 'annotation % not found', parent using errcode = 'P0002';
1544
+ end if;
1545
+ if acornly_private.is_deleted(p.annotation) then
1546
+ raise exception 'annotation % is deleted', parent using errcode = 'CH001';
1547
+ end if;
1548
+ -- A reply from before threads nested shows only on its page.
1549
+ if p.parent_id is null and p.thread_root_id is not null then
1550
+ raise exception 'annotation % is a reply from before threads nested; reply to its root',
1551
+ parent using errcode = 'CH001';
1552
+ end if;
1553
+ if p.scope <> child_scope then
1554
+ raise exception 'annotation % is not in its parent''s scope', a ->> 'id' using errcode = 'CH001';
1555
+ end if;
1556
+ if child_root is distinct from coalesce(p.thread_root_id, p.id) then
1557
+ raise exception 'annotation % is not in its parent''s thread', a ->> 'id'
1558
+ using errcode = 'CH001';
1559
+ end if;
1560
+ if (a #>> '{ext,depth}')::numeric
1561
+ is distinct from coalesce((p.annotation #>> '{ext,depth}')::numeric, 0) + 1
1562
+ then
1563
+ raise exception 'annotation % has the wrong depth', a ->> 'id' using errcode = 'CH001';
1564
+ end if;
1565
+ if not acornly_private.audience_within(child_audience, p.audience) then
1566
+ raise exception 'annotation % has a wider audience than its parent', a ->> 'id'
1567
+ using errcode = 'CH002';
1568
+ end if;
1569
+ quoted := jsonb_typeof(selectors) = 'array' and exists (
1570
+ select 1 from jsonb_array_elements(selectors) s where s ->> 'type' = 'TextQuoteSelector'
1571
+ );
1572
+ if quoted is distinct from (parent_rev is not null)
1573
+ or (parent_rev is not null and ((parent_rev #>> '{}')::numeric < 1
1574
+ or (parent_rev #>> '{}')::numeric > p.rev))
1575
+ then
1576
+ raise exception 'annotation % must record the parent''s rev exactly when it quotes the parent',
1577
+ a ->> 'id' using errcode = 'CH001';
1578
+ end if;
1579
+ end
1580
+ $$;
1581
+
1582
+ -- The audience of `note_id`, read as the owner and locked until the
1583
+ -- transaction ends, or 'public' when it is gone (nothing left to protect).
1584
+ create or replace function acornly_private.locked_audience(note_id text)
1585
+ returns text
1586
+ language sql
1587
+ volatile
1588
+ security definer
1589
+ set search_path = ''
1590
+ as $$
1591
+ select coalesce(
1592
+ (select a.audience from public.acornly_annotations a where a.id = note_id for share),
1593
+ 'public'
1594
+ )
1595
+ $$;
1596
+
1597
+ -- Whether the current statement runs inside `acornly_delete_node`, which
1598
+ -- writes placeholders and removes children. The flag alone is not enough, as
1599
+ -- any SQL session can set a custom setting: the statement must also run as the
1600
+ -- table's owner, which only that security-definer function does for a client.
1601
+ create or replace function acornly_private.in_delete_node()
1602
+ returns boolean
1603
+ language sql
1604
+ stable
1605
+ set search_path = ''
1606
+ as $$
1607
+ select coalesce(current_setting('acornly.delete_node', true), '') = 'on'
1608
+ and current_user = (
1609
+ select pg_catalog.pg_get_userbyid(c.relowner)
1610
+ from pg_catalog.pg_class c
1611
+ where c.oid = 'public.acornly_annotations'::regclass
1612
+ )
1613
+ $$;
1614
+
1615
+ -- The write trigger on notes.
1616
+ create or replace function acornly_private.prepare_annotation()
1617
+ returns trigger
1618
+ language plpgsql
1619
+ set search_path = ''
1620
+ as $$
1621
+ declare
1622
+ a jsonb := new.annotation;
1623
+ audience_kind text := a #>> '{ext,audience,kind}';
1624
+ -- The site's own server code writing on a user's behalf.
1625
+ trusted boolean := acornly_private.trusted_caller();
1626
+ begin
1627
+ if jsonb_typeof(a -> 'id') is distinct from 'string'
1628
+ or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
1629
+ or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
1630
+ or jsonb_typeof(a -> 'modified') is distinct from 'string'
1631
+ -- ISO 8601 only, so no `infinity`, `now` or other special values.
1632
+ or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
1633
+ or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
1634
+ then
1635
+ raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
1636
+ using errcode = '23514';
1637
+ end if;
1638
+ if coalesce(jsonb_typeof(a #> '{ext,depth}'), 'null') not in ('number', 'null')
1639
+ or coalesce(jsonb_typeof(a #> '{ext,parentRev}'), 'null') not in ('number', 'null')
1640
+ or coalesce(jsonb_typeof(a #> '{ext,deleted}'), 'null') not in ('boolean', 'null')
1641
+ then
1642
+ raise exception 'ext.depth and ext.parentRev must be numbers and ext.deleted a boolean'
1643
+ using errcode = '23514';
1644
+ end if;
1645
+
1646
+ if tg_op = 'INSERT' then
1647
+ if not trusted then
1648
+ -- Clients cannot write this column; the author is always the caller.
1649
+ new.creator_id := auth.uid();
1650
+ if new.creator_id is null then
1651
+ raise exception 'sign in to write notes' using errcode = '42501';
1652
+ end if;
1653
+ elsif new.creator_id is null then
1654
+ raise exception 'creator_id is required' using errcode = '23502';
1655
+ end if;
1656
+ new.rev := 1;
1657
+ else
1658
+ new.creator_id := old.creator_id;
1659
+ new.rev := old.rev + 1;
1660
+ end if;
1661
+
1662
+ if audience_kind is null or audience_kind not in ('private', 'public') then
1663
+ raise exception 'audience "%" is not supported here; use private or public', audience_kind
1664
+ using errcode = '23514';
1665
+ end if;
1666
+
1667
+ new.id := a ->> 'id';
1668
+ new.scope := a #>> '{ext,scope}';
1669
+ new.document := a #>> '{target,source}';
1670
+ new.audience := audience_kind;
1671
+ new.thread_root_id := a #>> '{ext,threadRoot}';
1672
+ -- The document never changes, so neither does the parent; notes stored
1673
+ -- before this migration keep theirs (none) when edited.
1674
+ new.parent_id := case
1675
+ when tg_op = 'UPDATE' then old.parent_id
1676
+ else acornly_private.parent_id_of(new.document)
1677
+ end;
1678
+ new.modified := (a ->> 'modified')::timestamptz;
1679
+ new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
1680
+
1681
+ if tg_op = 'INSERT' then
1682
+ if acornly_private.is_deleted(a) then
1683
+ raise exception 'a new annotation cannot be deleted' using errcode = 'CH001';
1684
+ end if;
1685
+ if new.parent_id is null then
1686
+ if new.thread_root_id is not null
1687
+ or a #> '{ext,depth}' is not null
1688
+ or a #> '{ext,parentRev}' is not null
1689
+ then
1690
+ raise exception 'annotation % has thread fields but targets a page', new.id
1691
+ using errcode = 'CH001';
1692
+ end if;
1693
+ else
1694
+ perform acornly_private.check_child(
1695
+ new.parent_id, a, new.scope, new.thread_root_id, new.audience
1696
+ );
1697
+ end if;
1698
+ return new;
1699
+ end if;
1700
+
1701
+ if new.id is distinct from old.id then
1702
+ raise exception 'id cannot change' using errcode = '23514';
1703
+ end if;
1704
+ -- `acornly_delete_node` writing a placeholder.
1705
+ if acornly_private.in_delete_node() then
1706
+ return new;
1707
+ end if;
1708
+ if acornly_private.is_deleted(a) is distinct from acornly_private.is_deleted(old.annotation) then
1709
+ raise exception 'annotation % is deleted or restored only by deleteNode', new.id
1710
+ using errcode = 'CH001';
1711
+ end if;
1712
+ if acornly_private.is_deleted(old.annotation) then
1713
+ raise exception 'annotation % is deleted', new.id using errcode = 'CH001';
1714
+ end if;
1715
+ if new.scope is distinct from old.scope
1716
+ or new.document is distinct from old.document
1717
+ or new.thread_root_id is distinct from old.thread_root_id
1718
+ or a #> '{ext,depth}' is distinct from old.annotation #> '{ext,depth}'
1719
+ or a #> '{ext,parentRev}' is distinct from old.annotation #> '{ext,parentRev}'
1720
+ or (old.parent_id is not null and a -> 'target' is distinct from old.annotation -> 'target')
1721
+ then
1722
+ raise exception 'annotation % cannot move: id, scope, target.source and threadRoot cannot change',
1723
+ new.id using errcode = 'CH001';
1724
+ end if;
1725
+ if a #> '{creator,id}' is distinct from old.annotation #> '{creator,id}'
1726
+ and acornly_private.has_children(new.id)
1727
+ then
1728
+ raise exception 'annotation % has replies; its author stays', new.id using errcode = 'CH001';
1729
+ end if;
1730
+ -- An unchanged or narrower audience is fine even if the parent was narrowed
1731
+ -- meanwhile. A wider one is checked against the parent, locked so a
1732
+ -- concurrent narrowing cannot slip past; a reply from before threads nested
1733
+ -- is checked against its root.
1734
+ if new.thread_root_id is not null
1735
+ and not acornly_private.audience_within(new.audience, old.audience)
1736
+ and not acornly_private.audience_within(
1737
+ new.audience,
1738
+ acornly_private.locked_audience(coalesce(old.parent_id, old.thread_root_id))
1739
+ )
1740
+ then
1741
+ raise exception 'annotation % cannot widen its parent''s audience', new.id
1742
+ using errcode = 'CH002';
1743
+ end if;
1744
+ return new;
1745
+ end
1746
+ $$;
1747
+
1748
+ -- Plain deletes keep the tree; `acornly_delete_node` and cascades (a
1749
+ -- deleted user's notes) are let through.
1750
+ create or replace function acornly_private.check_delete()
1751
+ returns trigger
1752
+ language plpgsql
1753
+ set search_path = ''
1754
+ as $$
1755
+ begin
1756
+ if acornly_private.in_delete_node() or pg_trigger_depth() > 1 then
1757
+ return old;
1758
+ end if;
1759
+ if old.parent_id is not null then
1760
+ raise exception 'annotation % is context on context; use threads.deleteNode', old.id
1761
+ using errcode = 'CH001';
1762
+ end if;
1763
+ if acornly_private.has_children(old.id) then
1764
+ raise exception 'annotation % has children; use threads.deleteNode', old.id
1765
+ using errcode = 'CH001';
1766
+ end if;
1767
+ return old;
1768
+ end
1769
+ $$;
1770
+
1771
+ -- The write trigger on versions.
1772
+ create or replace function acornly_private.prepare_version()
1773
+ returns trigger
1774
+ language plpgsql
1775
+ set search_path = ''
1776
+ as $$
1777
+ declare
1778
+ a jsonb := new.annotation;
1779
+ author uuid;
1780
+ begin
1781
+ if jsonb_typeof(a -> 'id') is distinct from 'string'
1782
+ or jsonb_typeof(a #> '{ext,rev}') is distinct from 'number'
1783
+ or (a #>> '{ext,rev}') !~ '^[1-9][0-9]{0,8}$'
1784
+ then
1785
+ raise exception 'a version needs a string id and a positive integer ext.rev'
1786
+ using errcode = '23514';
1787
+ end if;
1788
+ new.note_id := a ->> 'id';
1789
+ new.rev := (a #>> '{ext,rev}')::integer;
1790
+ if tg_op = 'UPDATE' and (new.note_id <> old.note_id or new.rev <> old.rev) then
1791
+ raise exception 'a saved version keeps its id and rev' using errcode = '23514';
1792
+ end if;
1793
+ -- Read as the caller: a client finds only notes it can read.
1794
+ select n.creator_id into author from public.acornly_annotations n where n.id = new.note_id;
1795
+ if not found then
1796
+ raise exception 'annotation % not found', new.note_id using errcode = 'P0002';
1797
+ end if;
1798
+ if not acornly_private.trusted_caller() and author is distinct from auth.uid() then
1799
+ raise exception 'only the author keeps versions of annotation %', new.note_id
1800
+ using errcode = '42501';
1801
+ end if;
1802
+ new.creator_id := author;
1803
+ return new;
1804
+ end
1805
+ $$;
1806
+
1807
+ -- The ids in a thread the caller may read: the read policy's rules worked out
1808
+ -- top-down in one pass, seeded with the root and its replies from before
1809
+ -- threads nested and then walking children through the parent index, so the
1810
+ -- cost is linear in the thread's size whatever its shape. The caller's own
1811
+ -- notes are always in, and everything for the site's server. Empty unless
1812
+ -- `root_id` is a note on a page in `thread_scope`.
1813
+ create or replace function acornly_private.thread_visible(root_id text, thread_scope text)
1814
+ returns setof text
1815
+ language sql
1816
+ stable
1817
+ security definer
1818
+ set search_path = ''
1819
+ as $$
1820
+ with recursive
1821
+ me (uid, trusted, scope_readable) as materialized (
1822
+ select auth.uid(),
1823
+ acornly_private.trusted_caller(),
1824
+ acornly_private.public_readable(thread_scope)
1825
+ ),
1826
+ tree (id, path_readable, depth) as (
1827
+ select a.id,
1828
+ coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false),
1829
+ 0
1830
+ from public.acornly_annotations a, me
1831
+ where a.id = root_id
1832
+ and a.scope = thread_scope
1833
+ and a.parent_id is null
1834
+ and a.thread_root_id is null
1835
+ union all
1836
+ -- Replies from before threads nested target the page and name the root.
1837
+ select c.id,
1838
+ r.path_readable
1839
+ and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
1840
+ 1
1841
+ from public.acornly_annotations a
1842
+ join public.acornly_annotations c
1843
+ on c.thread_root_id = a.id and c.parent_id is null and c.scope = thread_scope
1844
+ cross join me
1845
+ cross join lateral (
1846
+ select coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false)
1847
+ ) r (path_readable)
1848
+ where a.id = root_id and a.scope = thread_scope and a.thread_root_id is null
1849
+ union all
1850
+ select c.id,
1851
+ t.path_readable
1852
+ and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
1853
+ t.depth + 1
1854
+ from tree t
1855
+ join public.acornly_annotations c on c.parent_id = t.id
1856
+ cross join me
1857
+ where c.scope = thread_scope and t.depth < 10000
1858
+ )
1859
+ select t.id from tree t, me where t.path_readable or me.trusted
1860
+ union
1861
+ -- The caller's own notes, and everything for the site's server, even below a
1862
+ -- parent that is gone.
1863
+ select a.id
1864
+ from public.acornly_annotations a, me
1865
+ where a.scope = thread_scope
1866
+ and a.thread_root_id = root_id
1867
+ and (a.creator_id = me.uid or me.trusted)
1868
+ -- Only a real root starts a thread.
1869
+ and exists (select 1 from tree t where t.depth = 0)
1870
+ $$;
1871
+
1872
+ -- A thread's root and every node below it in one scope, as the caller may read
1873
+ -- them, as one JSON array of annotations ordered by `modified` and id. One call
1874
+ -- returns everything, as paging would redo the walk for each page.
1875
+ create or replace function public.acornly_list_thread(root_id text, thread_scope text)
1876
+ returns jsonb
1877
+ language sql
1878
+ stable
1879
+ security definer
1880
+ set search_path = ''
1881
+ as $$
1882
+ select coalesce(jsonb_agg(a.annotation order by a.modified, a.id), '[]'::jsonb)
1883
+ from acornly_private.thread_visible(root_id, thread_scope) v (id)
1884
+ join public.acornly_annotations a on a.id = v.id
1885
+ $$;
1886
+
1887
+ -- The number of direct children of each id that the caller may read. Replies
1888
+ -- from before threads nested are left out, as `assembleThread` does not place
1889
+ -- them. Ids with none are left out.
1890
+ -- Visibility comes from one top-down walk per thread involved.
1891
+ create or replace function public.acornly_count_children(ids text[])
1892
+ returns table (id text, children bigint)
1893
+ language sql
1894
+ stable
1895
+ security definer
1896
+ set search_path = ''
1897
+ as $$
1898
+ with children (parent, child, thread_root_id, scope) as materialized (
1899
+ select c.parent_id, c.id, c.thread_root_id, c.scope
1900
+ from public.acornly_annotations c
1901
+ where c.parent_id = any (ids)
1902
+ ),
1903
+ visible (id) as materialized (
1904
+ select v.id
1905
+ from (select distinct thread_root_id, scope from children) t
1906
+ cross join lateral acornly_private.thread_visible(t.thread_root_id, t.scope) v (id)
1907
+ )
1908
+ select c.parent, count(*)
1909
+ from children c
1910
+ join visible v on v.id = c.child
1911
+ group by c.parent
1912
+ $$;
1913
+
1914
+ -- Deletes `note_id` at `expected_rev` for its author (or the site's server).
1915
+ -- A note with any child becomes a placeholder at the next rev, modified at
1916
+ -- `deleted_at`: no body, author name or edited mark, `ext.deleted` true, and
1917
+ -- its versions dropped. One without children is removed, and so is each
1918
+ -- placeholder above it left with no children. Returns
1919
+ -- `{"placeholder": <annotation>}` or `{"removed": [{id, scope, document}, ...]}`,
1920
+ -- the note first.
1921
+ create or replace function public.acornly_delete_node(
1922
+ note_id text,
1923
+ expected_rev integer,
1924
+ deleted_at text
1925
+ )
1926
+ returns jsonb
1927
+ language plpgsql
1928
+ volatile
1929
+ security definer
1930
+ set search_path = ''
1931
+ as $$
1932
+ declare
1933
+ target public.acornly_annotations;
1934
+ node public.acornly_annotations;
1935
+ above public.acornly_annotations;
1936
+ placeholder jsonb;
1937
+ removed jsonb := '[]'::jsonb;
1938
+ trusted boolean := acornly_private.trusted_caller();
1939
+ begin
1940
+ select * into target from public.acornly_annotations a where a.id = note_id for update;
1941
+ if not found or not (trusted or acornly_private.can_read(target)) then
1942
+ raise exception 'annotation % not found', note_id using errcode = 'P0002';
1943
+ end if;
1944
+ if not trusted and target.creator_id is distinct from auth.uid() then
1945
+ raise exception 'Not allowed to change annotation %', note_id using errcode = '42501';
1946
+ end if;
1947
+ if target.rev <> expected_rev then
1948
+ raise exception 'annotation % is at rev %, expected %', note_id, target.rev, expected_rev
1949
+ using errcode = 'CH003', detail = target.rev::text;
1950
+ end if;
1951
+
1952
+ perform set_config('acornly.delete_node', 'on', true);
1953
+
1954
+ if acornly_private.has_children(note_id) then
1955
+ placeholder := (target.annotation - 'body') || jsonb_build_object('modified', deleted_at);
1956
+ placeholder := jsonb_set(
1957
+ placeholder,
1958
+ '{ext}',
1959
+ ((target.annotation -> 'ext') - 'edited') || '{"deleted": true}'::jsonb
1960
+ );
1961
+ if jsonb_typeof(target.annotation -> 'creator') = 'object' then
1962
+ placeholder := jsonb_set(
1963
+ placeholder,
1964
+ '{creator}',
1965
+ jsonb_strip_nulls(jsonb_build_object('id', target.annotation #> '{creator,id}'))
1966
+ );
1967
+ end if;
1968
+ update public.acornly_annotations a
1969
+ set annotation = placeholder
1970
+ where a.id = note_id
1971
+ returning a.annotation into placeholder;
1972
+ delete from public.acornly_versions v where v.note_id = target.id;
1973
+ perform set_config('acornly.delete_node', '', true);
1974
+ return jsonb_build_object('placeholder', placeholder);
1975
+ end if;
1976
+
1977
+ -- Remove it, then each placeholder above it left with no children. Each
1978
+ -- parent is locked before its children are counted, so a child landing
1979
+ -- meanwhile either waits for this delete or is seen.
1980
+ node := target;
1981
+ loop
1982
+ delete from public.acornly_annotations a where a.id = node.id;
1983
+ removed := removed || jsonb_build_object(
1984
+ 'id', node.id, 'scope', node.scope, 'document', node.document
1985
+ );
1986
+ exit when coalesce(node.parent_id, node.thread_root_id) is null;
1987
+ select * into above from public.acornly_annotations a
1988
+ where a.id = coalesce(node.parent_id, node.thread_root_id)
1989
+ for update;
1990
+ -- An orphan's parent went with its author: its root may still be waiting.
1991
+ if not found and node.parent_id is not null and node.thread_root_id is not null then
1992
+ select * into above from public.acornly_annotations a
1993
+ where a.id = node.thread_root_id
1994
+ for update;
1995
+ end if;
1996
+ exit when not found
1997
+ or not acornly_private.is_deleted(above.annotation)
1998
+ or acornly_private.has_children(above.id);
1999
+ node := above;
2000
+ end loop;
2001
+ perform set_config('acornly.delete_node', '', true);
2002
+ return jsonb_build_object('removed', removed);
2003
+ end
2004
+ $$;
2005
+ $acornly_migration$;
2006
+ insert into acornly_meta.migrations (version, name) values ('20261010070000', 'acornly_names');
2007
+ end if;
2008
+ end
2009
+ $acornly_install$;
2010
+
2011
+ -- 20261011080000_acornly_delete_node_grants.sql
2012
+ do $acornly_install$
2013
+ begin
2014
+ if not exists (select 1 from acornly_meta.migrations where version = '20261011080000') then
2015
+ raise notice 'acornly: applying %', '20261011080000_acornly_delete_node_grants.sql';
2016
+ execute $acornly_migration$
2017
+ -- Only signed-in callers (and the service role) may run `acornly_delete_node`,
2018
+ -- as the threads migration meant.
2019
+ --
2020
+ -- That migration revoked it from `public` and granted it to `authenticated`
2021
+ -- and `service_role`, but Supabase's default privileges give `anon` its own
2022
+ -- grant on every function created in `public`, and revoking from `public`
2023
+ -- leaves that grant in place. A visitor could not delete anything with it,
2024
+ -- since only a note's creator may and every note has one, but the function
2025
+ -- should refuse them before it looks a note up.
2026
+ revoke execute on function public.acornly_delete_node(text, integer, text) from anon;
2027
+ $acornly_migration$;
2028
+ insert into acornly_meta.migrations (version, name) values ('20261011080000', 'acornly_delete_node_grants');
2029
+ end if;
2030
+ end
2031
+ $acornly_install$;
2032
+
2033
+ -- 20261011150000_acornly_parent_ids.sql
2034
+ do $acornly_install$
2035
+ begin
2036
+ if not exists (select 1 from acornly_meta.migrations where version = '20261011150000') then
2037
+ raise notice 'acornly: applying %', '20261011150000_acornly_parent_ids.sql';
2038
+ execute $acornly_migration$
2039
+ -- A reply's target may name its parent under `urn:acornly:annotation:` as well as
2040
+ -- `urn:contexthive:annotation:` (docs/design/acornly-rename.md, phase 3). Without this, a reply
2041
+ -- written by a version of core that uses the new prefix is refused (CH001: thread fields on a note
2042
+ -- that targets a page).
2043
+ --
2044
+ -- create or replace keeps the function's owner and grants.
2045
+ create or replace function acornly_private.parent_id_of(source text)
2046
+ returns text
2047
+ language plpgsql
2048
+ stable
2049
+ set search_path = ''
2050
+ as $$
2051
+ declare
2052
+ prefix text;
2053
+ encoded text;
2054
+ decoded text;
2055
+ begin
2056
+ prefix := case
2057
+ when lower(left(source, 23)) = 'urn:acornly:annotation:' then 'urn:acornly:annotation:'
2058
+ when lower(left(source, 27)) = 'urn:contexthive:annotation:' then 'urn:contexthive:annotation:'
2059
+ end;
2060
+ if prefix is null then
2061
+ return null;
2062
+ end if;
2063
+ encoded := substr(source, length(prefix) + 1);
2064
+ if encoded !~ '^([^%]|%[0-9A-Fa-f]{2})*$' then
2065
+ raise exception 'target % is badly escaped', source using errcode = 'CH001';
2066
+ end if;
2067
+ begin
2068
+ select convert_from(
2069
+ coalesce(
2070
+ string_agg(
2071
+ case when m[1] is not null
2072
+ then decode(substr(m[1], 2), 'hex')
2073
+ else convert_to(m[2], 'UTF8')
2074
+ end,
2075
+ ''::bytea order by n
2076
+ ),
2077
+ ''::bytea
2078
+ ),
2079
+ 'UTF8'
2080
+ )
2081
+ into decoded
2082
+ from regexp_matches(encoded, '(%[0-9A-Fa-f]{2})|([^%]+)', 'g') with ordinality as t (m, n);
2083
+ exception when character_not_in_repertoire or untranslatable_character then
2084
+ raise exception 'target % is badly escaped', source using errcode = 'CH001';
2085
+ end;
2086
+ if decoded = '' then
2087
+ raise exception 'target % names an empty id', source using errcode = 'CH001';
2088
+ end if;
2089
+ return decoded;
2090
+ end
2091
+ $$;
2092
+ $acornly_migration$;
2093
+ insert into acornly_meta.migrations (version, name) values ('20261011150000', 'acornly_parent_ids');
2094
+ end if;
2095
+ end
2096
+ $acornly_install$;
2097
+
2098
+ -- 20261011170000_acornly_pdf_document_keys.sql
2099
+ do $acornly_install$
2100
+ begin
2101
+ if not exists (select 1 from acornly_meta.migrations where version = '20261011170000') then
2102
+ raise notice 'acornly: applying %', '20261011170000_acornly_pdf_document_keys.sql';
2103
+ execute $acornly_migration$
2104
+ -- Notes on PDFs are listed by the file's key (#403).
2105
+ --
2106
+ -- A note on a PDF carries `ext.documentKey`, `urn:x-pdf:<fingerprint>` (see
2107
+ -- `pdfDocumentKey` in @acornly/core), and `target.source` keeps the URL it was
2108
+ -- made on, so the same file opened from another URL shows its notes. The
2109
+ -- `document` column now holds that key, as `documentKeyOf` reads it: the key
2110
+ -- when it is a valid PDF key, else `target.source`. Notes stored before keep
2111
+ -- their source as their document.
2112
+ --
2113
+ -- The key is set when a note is made and never changes: an update that leaves
2114
+ -- it out, or names another, gets the stored one back, and a note's
2115
+ -- `target.source` stays as before. A reply's document is still its target
2116
+ -- (whatever key it carries), and its parent is still read from it. Notes stored
2117
+ -- before with a key already in their annotation move to it below.
2118
+
2119
+ -- `documentKeyOf` for a stored annotation. The trigger runs as its caller, so
2120
+ -- every role that writes notes may call it, as the other helpers it uses.
2121
+ create or replace function acornly_private.document_of(a jsonb)
2122
+ returns text
2123
+ language sql
2124
+ immutable
2125
+ set search_path = ''
2126
+ as $$
2127
+ select case
2128
+ when jsonb_typeof(a #> '{ext,documentKey}') = 'string'
2129
+ and (a #>> '{ext,documentKey}') ~ '^urn:x-pdf:[0-9a-f]{2,128}$'
2130
+ and (a #>> '{target,source}') !~* '^urn:(acornly|contexthive):annotation:'
2131
+ then a #>> '{ext,documentKey}'
2132
+ else a #>> '{target,source}'
2133
+ end
2134
+ $$;
2135
+
2136
+ revoke all on function acornly_private.document_of(jsonb) from public;
2137
+ grant execute on function acornly_private.document_of(jsonb)
2138
+ to anon, authenticated, service_role;
2139
+
2140
+ -- The write trigger on notes, from 20261010070000_acornly_names.sql with the
2141
+ -- document key.
2142
+ create or replace function acornly_private.prepare_annotation()
2143
+ returns trigger
2144
+ language plpgsql
2145
+ set search_path = ''
2146
+ as $$
2147
+ declare
2148
+ a jsonb := new.annotation;
2149
+ audience_kind text := a #>> '{ext,audience,kind}';
2150
+ -- The site's own server code writing on a user's behalf.
2151
+ trusted boolean := acornly_private.trusted_caller();
2152
+ begin
2153
+ if jsonb_typeof(a -> 'id') is distinct from 'string'
2154
+ or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
2155
+ or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
2156
+ or jsonb_typeof(a -> 'modified') is distinct from 'string'
2157
+ -- ISO 8601 only, so no `infinity`, `now` or other special values.
2158
+ or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
2159
+ or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
2160
+ then
2161
+ raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
2162
+ using errcode = '23514';
2163
+ end if;
2164
+ if coalesce(jsonb_typeof(a #> '{ext,depth}'), 'null') not in ('number', 'null')
2165
+ or coalesce(jsonb_typeof(a #> '{ext,parentRev}'), 'null') not in ('number', 'null')
2166
+ or coalesce(jsonb_typeof(a #> '{ext,deleted}'), 'null') not in ('boolean', 'null')
2167
+ then
2168
+ raise exception 'ext.depth and ext.parentRev must be numbers and ext.deleted a boolean'
2169
+ using errcode = '23514';
2170
+ end if;
2171
+
2172
+ if tg_op = 'INSERT' then
2173
+ if not trusted then
2174
+ -- Clients cannot write this column; the author is always the caller.
2175
+ new.creator_id := auth.uid();
2176
+ if new.creator_id is null then
2177
+ raise exception 'sign in to write notes' using errcode = '42501';
2178
+ end if;
2179
+ elsif new.creator_id is null then
2180
+ raise exception 'creator_id is required' using errcode = '23502';
2181
+ end if;
2182
+ new.rev := 1;
2183
+ else
2184
+ new.creator_id := old.creator_id;
2185
+ new.rev := old.rev + 1;
2186
+ -- A note's document key never changes: the stored one, or none, stays.
2187
+ a := case
2188
+ when old.annotation #> '{ext,documentKey}' is null then a #- '{ext,documentKey}'
2189
+ else jsonb_set(a, '{ext,documentKey}', old.annotation #> '{ext,documentKey}')
2190
+ end;
2191
+ end if;
2192
+
2193
+ if audience_kind is null or audience_kind not in ('private', 'public') then
2194
+ raise exception 'audience "%" is not supported here; use private or public', audience_kind
2195
+ using errcode = '23514';
2196
+ end if;
2197
+
2198
+ new.id := a ->> 'id';
2199
+ new.scope := a #>> '{ext,scope}';
2200
+ new.document := acornly_private.document_of(a);
2201
+ new.audience := audience_kind;
2202
+ new.thread_root_id := a #>> '{ext,threadRoot}';
2203
+ -- The document never changes, so neither does the parent; notes stored
2204
+ -- before this migration keep theirs (none) when edited.
2205
+ new.parent_id := case
2206
+ when tg_op = 'UPDATE' then old.parent_id
2207
+ else acornly_private.parent_id_of(a #>> '{target,source}')
2208
+ end;
2209
+ new.modified := (a ->> 'modified')::timestamptz;
2210
+ new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
2211
+
2212
+ if tg_op = 'INSERT' then
2213
+ if acornly_private.is_deleted(a) then
2214
+ raise exception 'a new annotation cannot be deleted' using errcode = 'CH001';
2215
+ end if;
2216
+ if new.parent_id is null then
2217
+ if new.thread_root_id is not null
2218
+ or a #> '{ext,depth}' is not null
2219
+ or a #> '{ext,parentRev}' is not null
2220
+ then
2221
+ raise exception 'annotation % has thread fields but targets a page', new.id
2222
+ using errcode = 'CH001';
2223
+ end if;
2224
+ else
2225
+ perform acornly_private.check_child(
2226
+ new.parent_id, a, new.scope, new.thread_root_id, new.audience
2227
+ );
2228
+ end if;
2229
+ return new;
2230
+ end if;
2231
+
2232
+ if new.id is distinct from old.id then
2233
+ raise exception 'id cannot change' using errcode = '23514';
2234
+ end if;
2235
+ -- `acornly_delete_node` writing a placeholder.
2236
+ if acornly_private.in_delete_node() then
2237
+ return new;
2238
+ end if;
2239
+ if acornly_private.is_deleted(a) is distinct from acornly_private.is_deleted(old.annotation) then
2240
+ raise exception 'annotation % is deleted or restored only by deleteNode', new.id
2241
+ using errcode = 'CH001';
2242
+ end if;
2243
+ if acornly_private.is_deleted(old.annotation) then
2244
+ raise exception 'annotation % is deleted', new.id using errcode = 'CH001';
2245
+ end if;
2246
+ if new.scope is distinct from old.scope
2247
+ or new.document is distinct from old.document
2248
+ or new.thread_root_id is distinct from old.thread_root_id
2249
+ or a #> '{ext,depth}' is distinct from old.annotation #> '{ext,depth}'
2250
+ or a #> '{ext,parentRev}' is distinct from old.annotation #> '{ext,parentRev}'
2251
+ or (old.parent_id is not null and a -> 'target' is distinct from old.annotation -> 'target')
2252
+ -- The document no longer says whether the source moved: a note on a PDF keeps its URL too.
2253
+ or a #> '{target,source}' is distinct from old.annotation #> '{target,source}'
2254
+ then
2255
+ raise exception 'annotation % cannot move: id, scope, target.source and threadRoot cannot change',
2256
+ new.id using errcode = 'CH001';
2257
+ end if;
2258
+ if a #> '{creator,id}' is distinct from old.annotation #> '{creator,id}'
2259
+ and acornly_private.has_children(new.id)
2260
+ then
2261
+ raise exception 'annotation % has replies; its author stays', new.id using errcode = 'CH001';
2262
+ end if;
2263
+ -- An unchanged or narrower audience is fine even if the parent was narrowed
2264
+ -- meanwhile. A wider one is checked against the parent, locked so a
2265
+ -- concurrent narrowing cannot slip past; a reply from before threads nested
2266
+ -- is checked against its root.
2267
+ if new.thread_root_id is not null
2268
+ and not acornly_private.audience_within(new.audience, old.audience)
2269
+ and not acornly_private.audience_within(
2270
+ new.audience,
2271
+ acornly_private.locked_audience(coalesce(old.parent_id, old.thread_root_id))
2272
+ )
2273
+ then
2274
+ raise exception 'annotation % cannot widen its parent''s audience', new.id
2275
+ using errcode = 'CH002';
2276
+ end if;
2277
+ return new;
2278
+ end
2279
+ $$;
2280
+
2281
+ -- Notes stored before this migration with a PDF key in their annotation (written
2282
+ -- after core gave notes keys) move to it. The write trigger would refuse the
2283
+ -- move, so it is off for this one statement.
2284
+ alter table public.acornly_annotations disable trigger acornly_annotations_prepare;
2285
+ update public.acornly_annotations
2286
+ set document = acornly_private.document_of(annotation)
2287
+ where document is distinct from acornly_private.document_of(annotation);
2288
+ alter table public.acornly_annotations enable trigger acornly_annotations_prepare;
2289
+ $acornly_migration$;
2290
+ insert into acornly_meta.migrations (version, name) values ('20261011170000', 'acornly_pdf_document_keys');
2291
+ end if;
2292
+ end
2293
+ $acornly_install$;
2294
+
2295
+ commit;