-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathschema.sql
More file actions
5974 lines (5574 loc) · 248 KB
/
schema.sql
File metadata and controls
5974 lines (5574 loc) · 248 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Nak — Supabase schema.
--
-- Canonical source of truth for the database shape. Applied to the
-- linked project by `mise run sync` (see scripts/sync.mjs and
-- .mise.toml), which pipes this file through the Supabase Management
-- API's `runSql` endpoint. Pasting into the Supabase SQL Editor is a
-- last-resort fallback — the convention is that every schema change
-- goes through `mise run sync` so there's exactly one application
-- path and one source of truth. Don't tell users to run statements
-- manually; tell them to `mise run sync`.
--
-- ---------------------------------------------------------------------------
-- Rules for edits
-- ---------------------------------------------------------------------------
--
-- `mise run sync` re-applies this file start-to-finish on every run.
-- There are no up/down migrations. Every statement must therefore be
-- safe to run against an already-migrated database. Patterns this
-- file uses, in rough order of preference:
--
-- - `create table if not exists`, `create index if not exists`,
-- `create extension if not exists`.
-- - `alter table ... add column if not exists`.
-- - `drop policy if exists` followed by `create policy ...` —
-- the project-wide pattern for editing RLS policies.
-- - `create or replace function`. For triggers, `drop trigger if
-- exists` then `create trigger`.
-- - Statements with no native `if not exists` (notably
-- `alter publication ... add table`) go inside a guarded
-- `do $$ begin if not exists (...) then ... end if; end $$;`
-- block that checks the relevant catalog first.
--
-- If you add a statement that can't be made idempotent, stop and
-- fix that before merging — the next `mise run sync` on a
-- previously-synced project will error out, and the error won't be
-- at your statement, it'll be on whoever syncs after you.
--
-- ---------------------------------------------------------------------------
-- Tables
-- ---------------------------------------------------------------------------
-- profiles one row per authenticated user (settings blob, timestamps)
-- threads conversation containers owned by a user
-- messages individual turns within a thread (incl. OpenAI-shape tool rows)
-- memories freeform notes CRUD-able by the user and the memory_* tools
-- recipes Cooklang recipes CRUD-able by the user and the recipe_* tools
--
-- All tables have Row Level Security enabled so an authenticated user
-- can only access rows they own. The anon key the browser uses is
-- safe to expose provided RLS policies stay in place.
create extension if not exists pgcrypto;
-- profiles ---------------------------------------------------------------
create table if not exists public.profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null unique references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
-- Per-user preferences (default model tier, future UI bits). The app only
-- writes known keys, but we use jsonb so additions don't require a schema
-- change. Defaults to an empty object so row-inserts don't need to set it.
alter table public.profiles
add column if not exists settings jsonb not null default '{}'::jsonb;
alter table public.profiles enable row level security;
drop policy if exists "profiles are self-visible" on public.profiles;
create policy "profiles are self-visible" on public.profiles
for select using (auth.uid() = user_id);
drop policy if exists "profiles are self-inserted" on public.profiles;
create policy "profiles are self-inserted" on public.profiles
for insert with check (auth.uid() = user_id);
drop policy if exists "profiles are self-updated" on public.profiles;
create policy "profiles are self-updated" on public.profiles
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
-- Auto-create a profile for new auth.users rows.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (user_id) values (new.id)
on conflict (user_id) do nothing;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- threads ----------------------------------------------------------------
create table if not exists public.threads (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
title text not null default 'New conversation',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Optional per-thread model tier override. Null means "use user default".
-- The app stores the tier name ('smart' | 'balanced' | 'fast') and resolves
-- it to a concrete Venice model id at send-time, so the column stays schema-
-- compatible even as tiers are retuned. No CHECK constraint on purpose —
-- garbage values are scrubbed by the app on read.
alter table public.threads
add column if not exists model text;
-- Optional per-thread reasoning_effort override ('low' | 'medium' | 'high').
-- Null means "use the user default" (profiles.settings.defaultReasoningEffort
-- → DEFAULT_REASONING_EFFORT). Plain text / no CHECK for the same reason as
-- `model` above: garbage is scrubbed by the app on read, and we want stored
-- rows to survive a future tier / provider change without a schema migration.
alter table public.threads
add column if not exists reasoning_effort text;
-- Optional per-thread text.verbosity override ('low' | 'medium' | 'high').
-- Null means "use the user default" (profiles.settings.defaultVerbosity →
-- DEFAULT_VERBOSITY). Same plain-text / no-CHECK rationale as `model` /
-- `reasoning_effort`: the app validates on read and we want stored rows to
-- survive a future tier / provider change without a schema migration.
alter table public.threads
add column if not exists verbosity text;
-- Removed 2026-04: web citations are now sourced from the client-side
-- `web_search` tool (see src/lib/tools/web_search.ts), not from a per-
-- thread or per-user toggle. The main chat loop never sets
-- `venice_parameters.enable_web_search` any more, so a per-thread
-- override for citations has nothing to override. Drop is idempotent
-- so re-applying on a fresh DB or a DB that never had the column is
-- a no-op.
alter table public.threads
drop column if exists web_citations_enabled;
-- "User has renamed this thread explicitly, don't auto-rename."
-- Flipped true when the user renames via the title input or materializes
-- a draft with an explicit title. Consulted by the chat loop to decide
-- whether to inject the title-note + rename instructions that drive the
-- `update_title` tool - when true, the model never sees the rename prompt
-- at all, so it can't clobber the user's choice. Non-null with a default
-- so existing rows pick up `false` without a backfill.
alter table public.threads
add column if not exists title_manually_set boolean not null default false;
-- Cached intuition payload for the most recent round it was computed
-- on. Holds {perception, drives:{...}, synthesis, computed_at_round,
-- computed_at_band, computed_at_confident, computed_at_at} - see
-- src/lib/intuition/types.ts for the canonical shape. Refreshed by
-- the chat-loop synchronously when (a) the model calls update_title
-- mid-turn or (b) the user's mood band/confidence changed since the
-- cache was last written, or (c) the staleness fuse trips after N
-- rounds without a refresh. Reused as-is on every other round so the
-- 7-call pipeline (perception + 5 drives + synthesis) doesn't run on
-- every chitchat turn. Null on cold-start threads; the first refresh
-- typically lands during turn 1 via the title trigger.
alter table public.threads
add column if not exists intuition_payload jsonb;
-- Cached context-recall payload. Sibling of intuition_payload, fired on
-- the same trigger machinery (cold-start, mood shift, stale fuse, mid-
-- turn title change) and keyed by the same computed_at_round / debounce
-- semantics. Holds {note, computed_at_round, computed_at_band,
-- computed_at_column, computed_at_at, trigger} - see
-- src/lib/context-recall/types.ts for the canonical shape. Stitches the
-- memory-recall and conversation-recall agents' first-person notes into
-- one short paragraph that the chat-loop injects as a synthetic
-- <think> assistant turn alongside the intuition block. Null on cold-
-- start threads; the first refresh typically lands during turn 1 via
-- the cold-start trigger.
alter table public.threads
add column if not exists context_recall_payload jsonb;
create index if not exists threads_user_updated_idx
on public.threads (user_id, updated_at desc);
alter table public.threads enable row level security;
drop policy if exists "threads are self-selectable" on public.threads;
create policy "threads are self-selectable" on public.threads
for select using (auth.uid() = user_id);
drop policy if exists "threads are self-insertable" on public.threads;
create policy "threads are self-insertable" on public.threads
for insert with check (auth.uid() = user_id);
drop policy if exists "threads are self-updatable" on public.threads;
create policy "threads are self-updatable" on public.threads
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
drop policy if exists "threads are self-deletable" on public.threads;
create policy "threads are self-deletable" on public.threads
for delete using (auth.uid() = user_id);
-- messages ---------------------------------------------------------------
create table if not exists public.messages (
id uuid primary key default gen_random_uuid(),
thread_id uuid not null references public.threads(id) on delete cascade,
role text not null check (role in ('system', 'user', 'assistant')),
content text not null,
created_at timestamptz not null default now()
);
create index if not exists messages_thread_created_idx
on public.messages (thread_id, created_at asc);
alter table public.messages enable row level security;
-- Access is gated by the parent thread's ownership.
drop policy if exists "messages are self-selectable via thread" on public.messages;
create policy "messages are self-selectable via thread" on public.messages
for select using (
exists (
select 1 from public.threads t
where t.id = messages.thread_id and t.user_id = auth.uid()
)
);
drop policy if exists "messages are self-insertable via thread" on public.messages;
create policy "messages are self-insertable via thread" on public.messages
for insert with check (
exists (
select 1 from public.threads t
where t.id = messages.thread_id and t.user_id = auth.uid()
)
);
drop policy if exists "messages are self-deletable via thread" on public.messages;
create policy "messages are self-deletable via thread" on public.messages
for delete using (
exists (
select 1 from public.threads t
where t.id = messages.thread_id and t.user_id = auth.uid()
)
);
-- Tool calling -----------------------------------------------------------
--
-- Messages gain an OpenAI-shaped tool-call payload so conversations
-- involving tool calls round-trip faithfully. The shape follows the
-- OpenAI chat completions API (which Venice mirrors):
--
-- role='assistant' with tool_calls[] — the model asked to invoke tools
-- role='tool' with tool_call_id — one row per tool execution result,
-- name, content=<string-encoded result>
--
-- Keeping this shape on the wire means history → API becomes a direct
-- projection and future providers drop in with no schema churn.
-- Replace the role check to include 'tool'. Drop-and-recreate is safe here
-- because we control the only writer.
alter table public.messages drop constraint if exists messages_role_check;
alter table public.messages
add constraint messages_role_check
check (role in ('system', 'user', 'assistant', 'tool'));
-- Assistant rows that produced tool calls carry the raw array; we keep it
-- as jsonb so the OpenAI shape (`[{id, type, function: {name, arguments}}]`)
-- lands untouched. Null on every other row.
alter table public.messages
add column if not exists tool_calls jsonb;
-- Tool-result rows reference the assistant call they answer. `name` echoes
-- the tool that was invoked (OpenAI includes it on the `tool` message too).
-- Both null on non-tool rows.
alter table public.messages
add column if not exists tool_call_id text;
alter table public.messages
add column if not exists name text;
-- Per-message provenance for assistant rows. `model` is the concrete Venice
-- model id that produced this response (e.g. 'kimi-k2-5'), captured at
-- send-time — not the abstract tier — so the row stays truthful even when
-- a tier is later re-pointed to a different backend. Null on non-assistant
-- rows and on assistant rows written before this column existed.
alter table public.messages
add column if not exists model text;
-- OpenAI-shaped token usage block for the turn that produced this assistant
-- row: `{prompt_tokens, completion_tokens, total_tokens}`. Sourced from the
-- `usage` epilogue frame that Venice emits when we pass
-- `stream_options: { include_usage: true }`. Drives the context-window
-- indicator on the message card. Null when usage wasn't reported (the
-- provider declined, or the stream was cut short).
alter table public.messages
add column if not exists usage jsonb;
-- Chain-of-thought text emitted by reasoning-capable models on
-- `delta.reasoning_content` during streaming. Stored separately from
-- `content` so the visible answer renders without mixing in the
-- thinking tokens — and so the UI can surface it in its own
-- collapsible "thought" panel. Null on non-assistant rows, on older
-- rows written before this column existed, and on turns where the
-- model didn't emit any reasoning.
alter table public.messages
add column if not exists reasoning text;
-- Venice web-search citations array in the shape the API returns on
-- `venice_parameters.web_search_citations`:
-- [{index, title?, url, content?, date?}, ...]
-- Inline `^N^` / `^i,j^` superscripts in `content` index into this
-- array (1-based). Null when citations weren't requested, weren't
-- produced, or on rows older than this column. jsonb (not jsonb[])
-- so the whole list travels as a single typed blob matching the
-- wire shape.
alter table public.messages
add column if not exists citations jsonb;
-- Per-thread set of enabled gated toolboxes. Stored as text[] so the
-- toolbox dimension sits in the thread row without a second table.
-- The always_on toolbox is implicit and is NOT represented here - its
-- tools ride every request regardless. Names are validated client-
-- side against `GATED_TOOLBOX_NAMES` in src/lib/tools/index.ts;
-- unknown names are silently dropped on both the model path
-- (`toggle_toolbox`) and the UI path (composer popover), so a renamed
-- or deleted toolbox does not break mid-flight.
--
-- The LLM can flip this via the `toggle_toolbox` meta-tool; the user
-- can flip it via the composer toolbox popover. Both paths write
-- through the same column.
alter table public.threads
add column if not exists toolboxes_enabled text[] not null default '{}';
-- Backfill from the legacy boolean `tools_enabled` column, then drop
-- it. Runs in a guarded block so it's safe on projects that have
-- already migrated (the information_schema probe short-circuits) and
-- on projects that never had the boolean column (same). The array we
-- backfill with is the full set of gated toolboxes at migration time
-- (`cooking`, `memories`, `conversations`) - any thread that had
-- tools_enabled=true gets the same capability set it had before. The
-- list is hard-coded rather than derived because "everything that
-- existed when we migrated" is a one-shot decision that must not
-- drift when we add a new toolbox in a later release.
do $$
begin
if exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'threads'
and column_name = 'tools_enabled'
) then
update public.threads
set toolboxes_enabled = array['cooking', 'memories', 'conversations']::text[]
where tools_enabled = true
and toolboxes_enabled = '{}'::text[];
alter table public.threads drop column tools_enabled;
end if;
end $$;
-- Soft-hide flag for the "Archive" drawer section. Archived threads still
-- load into the sidebar and remain viewable, but the composer is disabled
-- in the UI and they're rendered under a separate collapsed section.
-- Restoring flips this back to false and bumps updated_at so the thread
-- reappears at the top of the Chats list. Existing RLS (auth.uid() =
-- user_id) already covers both states — no policy change needed.
alter table public.threads
add column if not exists archived boolean not null default false;
-- message_attachments ----------------------------------------------------
--
-- One row per file a user attached to a message. The file bytes live
-- in `data` as base64-encoded `text` — not `bytea`. The original
-- design used bytea, but PostgREST serialises bytea as a hex-escaped
-- string (`\x4869...`) on both read and write, which our client code
-- assumed was base64 and fed straight into `atob()`. Storing base64
-- as text removes the encoding ambiguity entirely: what goes in is
-- what comes out, it's directly usable by `atob`, and the ~33%
-- storage overhead is negligible under the 10 MB per-file cap.
--
-- `extracted_text` is populated at upload time for non-image files by
-- calling Venice's POST /api/v1/augment/text-parser endpoint, so the
-- LLM has a prompt-ready representation of documents without the
-- client having to bundle a PDF parser. It lives alongside `data` on
-- purpose: even after the binary is expired and reclaimed, the
-- extracted text stays, so re-reading an old conversation still shows
-- what the file said.
--
-- Expiration policy: the attachment_expiry worker nulls `data` and
-- stamps `expired_at` 30 days after the parent thread's `updated_at`.
-- `filename`, `mime_type`, `size_bytes`, and `extracted_text` are kept
-- so the message list can still render "<file>: <expired icon> |
-- [extracted text]". `data is null and expired_at is not null` is the
-- expired state; `data is not null and expired_at is null` is live.
--
-- No `updated_at` — attachments are immutable once written (the
-- expiry worker is the only writer post-insert, and it only nulls
-- the blob). RLS is via-parent-of-parent: attachment → message →
-- thread → user, mirroring the messages policies one level deeper.
create table if not exists public.message_attachments (
id uuid primary key default gen_random_uuid(),
message_id uuid not null references public.messages(id) on delete cascade,
position int not null default 0,
filename text not null,
mime_type text not null,
size_bytes int not null,
data text,
extracted_text text,
expired_at timestamptz,
created_at timestamptz not null default now()
);
-- Migrate the `data` column from bytea to text for projects synced
-- under the original design. Idempotent: the information_schema
-- check short-circuits on freshly-synced databases (where the column
-- is already text) and on subsequent syncs after the migration runs
-- (same reason). We drop + re-add rather than `alter column ... type
-- text using encode(data, 'base64')` because pre-migration rows hold
-- bytes under an ambiguous PostgREST encoding — re-encoding garbage
-- doesn't restore the original files. Post-migration, any rows that
-- existed before render as "expired" (data is null, extracted_text
-- preserved where populated) which matches the expired-attachment
-- rendering the message list already handles gracefully.
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'message_attachments'
and column_name = 'data'
and data_type = 'bytea'
) then
-- Drop the dependent partial index first; `alter column ... type`
-- would preserve it implicitly but we're dropping the column.
-- `create index if not exists` further down recreates it.
drop index if exists public.message_attachments_live_idx;
alter table public.message_attachments drop column data;
alter table public.message_attachments add column data text;
end if;
end $$;
create index if not exists message_attachments_message_idx
on public.message_attachments (message_id, position);
-- Partial index used by the expiration worker. Only carries live
-- (non-expired) rows so the scan to find expirable attachments stays
-- tiny in steady state — the bulk of history is already expired and
-- excluded from the index.
create index if not exists message_attachments_live_idx
on public.message_attachments (message_id)
where data is not null;
alter table public.message_attachments enable row level security;
-- Access is gated by the owning thread's user_id, reached via the
-- message FK. Same via-parent pattern as messages, one level deeper.
drop policy if exists "attachments are self-selectable via thread"
on public.message_attachments;
create policy "attachments are self-selectable via thread"
on public.message_attachments
for select using (
exists (
select 1
from public.messages m
join public.threads t on t.id = m.thread_id
where m.id = message_attachments.message_id
and t.user_id = auth.uid()
)
);
drop policy if exists "attachments are self-insertable via thread"
on public.message_attachments;
create policy "attachments are self-insertable via thread"
on public.message_attachments
for insert with check (
exists (
select 1
from public.messages m
join public.threads t on t.id = m.thread_id
where m.id = message_attachments.message_id
and t.user_id = auth.uid()
)
);
drop policy if exists "attachments are self-updatable via thread"
on public.message_attachments;
create policy "attachments are self-updatable via thread"
on public.message_attachments
for update using (
exists (
select 1
from public.messages m
join public.threads t on t.id = m.thread_id
where m.id = message_attachments.message_id
and t.user_id = auth.uid()
)
) with check (
exists (
select 1
from public.messages m
join public.threads t on t.id = m.thread_id
where m.id = message_attachments.message_id
and t.user_id = auth.uid()
)
);
drop policy if exists "attachments are self-deletable via thread"
on public.message_attachments;
create policy "attachments are self-deletable via thread"
on public.message_attachments
for delete using (
exists (
select 1
from public.messages m
join public.threads t on t.id = m.thread_id
where m.id = message_attachments.message_id
and t.user_id = auth.uid()
)
);
-- Expiration RPC. Reclaims the binary for attachments whose owning
-- thread hasn't been touched in `p_days`. Runs as the caller (RLS
-- intact). The `limit` keeps each call's work bounded — the worker
-- drains the backlog by calling repeatedly while the row count is
-- non-zero, then naps for an hour when it returns 0.
--
-- We don't delete the row — we null `data` and stamp `expired_at`.
-- `filename`, `mime_type`, `size_bytes`, and `extracted_text` stay so
-- the message list can still render a "this file expired" entry with
-- the original name and the text the model saw. Conversations read a
-- year later still make sense.
drop function if exists public.expire_old_attachments(int);
create or replace function public.expire_old_attachments(
p_days int
) returns int
language plpgsql security invoker as $$
declare
affected int;
begin
with stale as (
select a.id
from public.message_attachments a
join public.messages m on m.id = a.message_id
join public.threads t on t.id = m.thread_id
where t.user_id = auth.uid()
and a.data is not null
and t.updated_at < now() - make_interval(days => p_days)
limit 500
for update skip locked
)
update public.message_attachments a
set data = null,
expired_at = now()
from stale s
where a.id = s.id;
get diagnostics affected = row_count;
return affected;
end $$;
-- Reflection pipeline ----------------------------------------------------
--
-- The memory-reflection agent (src/lib/agents/reflection/*) sweeps
-- completed conversations and updates long-term memory based on what it
-- learned. These columns on `threads` are the ground truth for two
-- questions:
--
-- 1. "Has this thread been reflected on since its last terminal
-- assistant response?" — answered by comparing
-- `last_reflected_msg_id` to the newest terminal assistant message
-- in the thread.
-- 2. "Is this thread currently being reflected on by some device?" —
-- answered by `reflection_holder_id`/`reflection_claim_expires_at`
-- (same per-row-claim pattern memories uses for embeddings).
--
-- A message id is the pointer rather than a timestamp because message
-- ids are stable and comparable without clock-skew worries across
-- devices. "Terminal assistant message" means a row with role='assistant',
-- no tool_calls (the tool round resolved), and non-null content — a
-- failed / empty response doesn't count as a round worth reflecting on.
alter table public.threads
add column if not exists last_reflected_msg_id uuid references public.messages(id) on delete set null,
add column if not exists reflection_holder_id text,
add column if not exists reflection_claim_expires_at timestamptz;
-- Claim-lookup index. Partial on `reflection_holder_id is not null` so
-- the index only carries live claims — the common case is 0 rows
-- claimed, and a partial index stays tiny under that steady state.
create index if not exists threads_reflection_claim_idx
on public.threads (reflection_claim_expires_at)
where reflection_holder_id is not null;
-- Summarisation + search pipeline ----------------------------------------
--
-- Two workers cooperate to make conversations searchable:
--
-- 1. The summary agent (src/lib/agents/summary/*) takes a thread and
-- writes a 2–3 sentence topical summary into `threads.summary`.
-- `last_summarised_msg_id` points at the terminal assistant
-- message we've summarised up to — same shape as
-- `last_reflected_msg_id`, same reasons (stable ids, no clock
-- skew). The per-thread claim columns mirror the reflection
-- agent exactly; the top-rail lease is a separate worker_kind
-- ('summary') so a device can hold summary + reflection +
-- embedding leases simultaneously.
--
-- 2. The embeddings worker (src/lib/embeddings/*) then embeds
-- `title + summary` into `embedding` so the search RPC below can
-- cosine-rank threads against a query vector. The trigger in
-- `clear_thread_embedding_on_change` wipes the embedding when
-- either input changes, so the worker picks the row up again on
-- its next poll.
--
-- `embedding` is vector(2048) to match memories — same padding helper,
-- same forward-compat headroom for a future native-2048 model. No HNSW
-- index for the same reason memories skip it: per-user thread counts
-- stay tiny (hundreds at most), so seq scan is fast enough; halfvec +
-- HNSW is the escape hatch if that ever stops being true.
alter table public.threads
add column if not exists summary text,
add column if not exists last_summarised_msg_id uuid references public.messages(id) on delete set null,
add column if not exists summary_claim_holder text,
add column if not exists summary_claim_expires timestamptz,
add column if not exists embedding vector(2048),
add column if not exists embedding_model text,
add column if not exists embedding_claim_holder text,
add column if not exists embedding_claim_expires timestamptz;
-- Partial claim indexes: same shape as the reflection one. Only carry
-- live claims so the index stays tiny in steady state.
create index if not exists threads_summary_claim_idx
on public.threads (summary_claim_expires)
where summary_claim_holder is not null;
create index if not exists threads_embedding_claim_idx
on public.threads (embedding_claim_expires)
where embedding_claim_holder is not null;
-- Invalidate the embedding whenever its inputs change. Pending =
-- `embedding is null`, so the embeddings worker will re-embed on its
-- next poll. We null the claim columns too — an in-flight worker save
-- would otherwise land a stale embedding, since its guard checks
-- `claim_holder = $me and claim_expires > now()` and both of those
-- would still match without this clear. Same invariant as the memories
-- trigger.
create or replace function public.clear_thread_embedding_on_change()
returns trigger language plpgsql as $$
begin
if new.title is distinct from old.title
or new.summary is distinct from old.summary then
new.embedding := null;
new.embedding_model := null;
new.embedding_claim_holder := null;
new.embedding_claim_expires := null;
end if;
return new;
end $$;
drop trigger if exists clear_thread_embedding_on_change on public.threads;
create trigger clear_thread_embedding_on_change
before update on public.threads
for each row execute function public.clear_thread_embedding_on_change();
-- memories ---------------------------------------------------------------
--
-- Free-form notes the user (or the LLM via the memory_* tools) can CRUD
-- and search. `data` is plain text by design — we want the LLM to be able
-- to read and write it directly without a schema it has to learn.
--
-- The `embedding` column is sized at 2048 dims for forward compatibility.
-- Venice's current embeddings model (text-embedding-bge-m3) emits 1024
-- dims; the worker zero-pads to 2048 before storing. Cosine similarity is
-- invariant to the extra zeros (the padded suffix contributes nothing to
-- the dot product and scales both vectors' norms identically), so we can
-- eventually switch to a native-2048 model without a column-type
-- migration. See src/lib/models.ts for the padding helper.
--
-- No HNSW index: pgvector caps HNSW at 2000 dims for the `vector` type
-- (halfvec goes to 4000 but trades precision). Sequential scan is plenty
-- at memories-scale — a few ms at 10k rows per user. If we ever outgrow
-- seq scan, the escape hatch is to switch `embedding` to halfvec(2048)
-- and add the HNSW index then.
--
-- A background Web Worker (src/lib/embeddings/*) populates the column on
-- a poll of `where embedding is null`; rows stay pending until the worker
-- catches up, and `memory_search` falls back to ILIKE for those
-- unembedded rows so a just-written memory is never invisible.
--
-- `embedding_model` records which Venice model produced the vector. A
-- future model rotation reselects stale rows with
-- `where embedding_model <> $current` without a schema change.
--
-- `embedding_claim_holder` / `embedding_claim_expires` implement the
-- per-row lease for cross-device coordination. See the lease table below
-- for the full picture — the short version: when a worker on device A is
-- embedding a row, the claim columns are stamped so device B (if it ever
-- holds the lease) skips the row until the claim expires, preventing
-- duplicate Venice billing across devices.
create extension if not exists vector;
create table if not exists public.memories (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
label text not null,
data text not null,
embedding vector(2048),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Columns added after the initial table ship. `add column if not exists`
-- keeps every alter statement idempotent across re-runs of the script.
alter table public.memories
add column if not exists embedding_model text,
add column if not exists embedding_claim_holder text,
add column if not exists embedding_claim_expires timestamptz,
-- Confidence that this memory is still valid. Starts at 1.0 on
-- create; the reflection agent's `memory_invalidate` halves it when
-- the agent thinks a memory has been contradicted by new evidence.
-- The memory-search RPC floors at 0.05 (effectively hides the row
-- from search without hard-deleting — recoverable if the agent
-- re-learns the fact) and applies a logarithmic boost to the
-- similarity score so corroborated memories (`memory_update` calls
-- `bump_memory_confidence`, adding 1.0 up to a cap of 10.0) rank
-- higher than single-occurrence ones.
add column if not exists confidence real not null default 1.0;
-- Upgrade path for projects that shipped with vector(1024) before the
-- pad-to-2048 decision. Guarded so fresh projects (already created at
-- vector(2048) above) skip the block entirely. We null the embedding
-- column first because ALTER TYPE on a vector column with mismatched-dim
-- rows would error. The null is safe — any previously-populated vector
-- gets re-embedded by the worker on the next poll, and
-- `embedding_model` is nulled alongside so memory_search knows it's
-- pending. No HNSW index to drop on old projects since we never shipped
-- one against 1024-dim either.
do $$
declare
current_type text;
begin
select format_type(atttypid, atttypmod) into current_type
from pg_attribute
where attrelid = 'public.memories'::regclass
and attname = 'embedding'
and not attisdropped;
if current_type = 'vector(1024)' then
drop index if exists memories_embedding_hnsw;
update public.memories set embedding = null, embedding_model = null;
alter table public.memories alter column embedding type vector(2048);
end if;
end $$;
-- A prior revision of this file shipped an HNSW index. Drop it
-- unconditionally — see the "No HNSW index" comment at the top of this
-- section for rationale.
drop index if exists memories_embedding_hnsw;
create index if not exists memories_user_updated_idx
on public.memories (user_id, updated_at desc);
-- Invalidate the embedding whenever the text that produced it changes.
-- Pending = `embedding is null`, so once the trigger fires the worker
-- will re-embed on its next poll. We null the claim columns too — an
-- in-flight worker save would otherwise land a now-stale embedding,
-- since its guard checks `claim_holder = $me and claim_expires > now()`
-- and both of those would still match without this clear.
create or replace function public.clear_memory_embedding_on_change()
returns trigger language plpgsql as $$
begin
if new.label is distinct from old.label or new.data is distinct from old.data then
new.embedding := null;
new.embedding_model := null;
new.embedding_claim_holder := null;
new.embedding_claim_expires := null;
end if;
return new;
end $$;
drop trigger if exists clear_memory_embedding_on_change on public.memories;
create trigger clear_memory_embedding_on_change
before update on public.memories
for each row execute function public.clear_memory_embedding_on_change();
alter table public.memories enable row level security;
drop policy if exists "memories are self-selectable" on public.memories;
create policy "memories are self-selectable" on public.memories
for select using (auth.uid() = user_id);
drop policy if exists "memories are self-insertable" on public.memories;
create policy "memories are self-insertable" on public.memories
for insert with check (auth.uid() = user_id);
drop policy if exists "memories are self-updatable" on public.memories;
create policy "memories are self-updatable" on public.memories
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
drop policy if exists "memories are self-deletable" on public.memories;
create policy "memories are self-deletable" on public.memories
for delete using (auth.uid() = user_id);
-- memory_relations -------------------------------------------------------
--
-- The volitional-memory layer's graph. Each row is a directed edge the
-- LLM (or the user) drew between two memories. Four kinds:
-- supports - target reinforces the source's claim.
-- contradicts - target disagrees with the source (stored
-- asymmetrically; the LLM chooses direction).
-- generalises - target is a broader version of the source.
-- specialises - target is a narrower/concrete case of the source.
--
-- Cycles are legal. Retrieval bounds traversal depth (1 hop in v1) and
-- caps the fan-out per source so a runaway web of edges can't blow the
-- priming budget. `get_memory_relations` below is the retrieval primitive
-- the opening-recall and memory_search paths use.
--
-- `on delete cascade` on both foreign keys means deleting a memory
-- sweeps its edges automatically — no orphan rows, no code-side cleanup.
--
-- The `(user_id, from_memory_id, to_memory_id, kind)` unique constraint
-- prevents the LLM from double-inserting the same edge on a repeated
-- tool call. The chat-side tool still rejects self-loops (from_id =
-- to_id) at the wire boundary so the constraint-violation path stays for
-- the "same edge twice" case the LLM might actually trip.
--
-- Companion to `memories.confidence`: relations annotate the graph,
-- confidence annotates the node. Both surface in injected memory text
-- and the Memories.svelte UI so Jeff can QA what the LLM has built.
create table if not exists public.memory_relations (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
from_memory_id uuid not null references public.memories(id) on delete cascade,
to_memory_id uuid not null references public.memories(id) on delete cascade,
kind text not null check (
kind in ('supports', 'contradicts', 'generalises', 'specialises')
),
note text,
created_at timestamptz not null default now(),
unique (user_id, from_memory_id, to_memory_id, kind)
);
create index if not exists memory_relations_from_idx
on public.memory_relations (user_id, from_memory_id);
create index if not exists memory_relations_to_idx
on public.memory_relations (user_id, to_memory_id);
alter table public.memory_relations enable row level security;
drop policy if exists "memory_relations are self-selectable"
on public.memory_relations;
create policy "memory_relations are self-selectable"
on public.memory_relations
for select using (auth.uid() = user_id);
drop policy if exists "memory_relations are self-insertable"
on public.memory_relations;
create policy "memory_relations are self-insertable"
on public.memory_relations
for insert with check (auth.uid() = user_id);
drop policy if exists "memory_relations are self-updatable"
on public.memory_relations;
create policy "memory_relations are self-updatable"
on public.memory_relations
for update using (auth.uid() = user_id)
with check (auth.uid() = user_id);
drop policy if exists "memory_relations are self-deletable"
on public.memory_relations;
create policy "memory_relations are self-deletable"
on public.memory_relations
for delete using (auth.uid() = user_id);
-- recipes ----------------------------------------------------------------
--
-- Cooklang recipes the user authors in Nak (often by asking the model to
-- fetch one from a URL and save it). The store is deliberately simple —
-- the canonical representation is a single `cooklang` text column holding
-- the recipe's full source. All structure (ingredients, cookware, timers,
-- metadata) is re-derived at read-time by `src/lib/cooklang.ts`, so a
-- future spec change doesn't require a data migration.
--
-- `source` is an optional free-form provenance string (e.g. "NYT
-- Cooking — Alison Roman" or "my grandmother"); `source_url` is the
-- machine-readable URL when the model imported it from the web. Both
-- nullable because hand-typed recipes often have neither.
--
-- Embedding column added later (see "Recipe embeddings" section below).
-- Original design omitted it on the rationale that ILIKE-on-title is
-- enough for a single-user cookbook; that holds for the LLM tool path
-- but the drawer's recipe search is a human surface where a fuzzy
-- "fluffy potato side" should find "Mashed Potatoes." Vector storage
-- mirrors memories / wiki: 2048-padded, written by the shared
-- embeddings worker. The default ILIKE-on-title still works for
-- callers that pass no embedding (e.g. the `recipe_list` tool).
create table if not exists public.recipes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
title text not null,
source text,
source_url text,
cooklang text not null,
-- User rating, 1-5 stars. Null means "unrated"; clearing the stars in
-- the UI writes null rather than 0 so the unrated case is
-- distinguishable from "actively rated zero" (which we don't allow).
rating smallint,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Idempotent column add for projects synced before the rating rollout.
-- `add column if not exists` is enough on its own - the type is
-- compatible with existing null-only data, and the constraint below
-- guards new writes.
alter table public.recipes
add column if not exists rating smallint;
-- 1-5 stars, or null. Wrapped in a do-block because `add constraint`
-- has no `if not exists` form; checking pg_constraint keeps the
-- statement re-runnable.
do $$ begin
if not exists (
select 1 from pg_constraint
where conname = 'recipes_rating_check'
and conrelid = 'public.recipes'::regclass
) then
alter table public.recipes
add constraint recipes_rating_check
check (rating is null or (rating between 1 and 5));
end if;
end $$;
create index if not exists recipes_user_updated_idx
on public.recipes (user_id, updated_at desc);
alter table public.recipes enable row level security;
drop policy if exists "recipes are self-selectable" on public.recipes;
create policy "recipes are self-selectable" on public.recipes
for select using (auth.uid() = user_id);
drop policy if exists "recipes are self-insertable" on public.recipes;
create policy "recipes are self-insertable" on public.recipes
for insert with check (auth.uid() = user_id);
drop policy if exists "recipes are self-updatable" on public.recipes;
create policy "recipes are self-updatable" on public.recipes
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
drop policy if exists "recipes are self-deletable" on public.recipes;
create policy "recipes are self-deletable" on public.recipes
for delete using (auth.uid() = user_id);
-- recipe_versions --------------------------------------------------------
--
-- Immutable change log for `recipes`. Every create and every update
-- writes one snapshot row here capturing the full editable state
-- (title, cooklang, source, source_url) plus a required free-form
-- `change_message` describing the edit. The most-recent version row
-- always matches the corresponding `recipes` row by content; the
-- `recipes` row stays the denormalized cache so hot reads (list,
-- detail pane, drawer tab) remain one-table and one-index.
--
-- Why both: every read path today projects directly off `recipes` and
-- `cookbook.recipes[]` is denormalized too. A `current_version_id`
-- pointer would force a join on every read for no user-visible win,
-- since history is a cold path opened only when the user clicks into
-- it. Mirroring the current row into `recipe_versions` on every
-- mutation costs O(recipe size) bytes per edit, which is trivial at
-- single-user cookbook scale.
--
-- Retention is unbounded by design - the user opted in to keeping
-- every revision so the History panel reads as a complete diary.
--
-- Versions are immutable: select / insert policies only. A cascade
-- delete from `recipes` is the only way a row leaves this table.
create table if not exists public.recipe_versions (
id uuid primary key default gen_random_uuid(),
recipe_id uuid not null references public.recipes(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
title text not null,
source text,
source_url text,
cooklang text not null,
-- Snapshot of `recipes.rating` at the time of the save. Same
-- semantics as the parent column (null = unrated, otherwise 1-5),
-- so a revert restores the rating along with the rest of the
-- editable state.
rating smallint,
change_message text not null,
created_at timestamptz not null default now()
);
-- Idempotent column add for projects synced before the rating rollout.
alter table public.recipe_versions
add column if not exists rating smallint;