feat(v1.1.2-docs): migrations + shared DocsService trait + TriggerEvent::Docs

Migrations 0013_docs.sql + 0014_docs_triggers.sql land the docs table
(JSONB body + GIN-on-jsonb_path_ops index, PK keyed on (app_id,
collection, id) for cross-app isolation) and widen the triggers.kind
and outbox.source_kind CHECK constraints to include 'docs', plus the
docs_trigger_details detail table mirroring kv_trigger_details.

picloud-shared grows the DocsService trait + DocRow/DocsListPage/
DocsError + NoopDocsService, the TriggerEvent::Docs variant with the
prev_data change-data-capture surface, the DocsEventOp enum, the docs
field on the Services bundle, and the SDK_VERSION bump 1.2 -> 1.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-02 19:54:56 +02:00
parent 28a3bbd37f
commit 3af8cc38c9
7 changed files with 408 additions and 8 deletions

View File

@@ -0,0 +1,39 @@
-- v1.1.2: Documents — schemaless JSONB store with basic query semantics.
--
-- Identity tuple `(app_id, collection, id)`. `id` is a server-generated
-- UUID; scripts never supply it on create. `app_id` is first in the
-- primary key so the implicit index is always per-app — cross-app reads
-- are impossible even under a buggy query.
--
-- `data` is JSONB so scripts can store nested structures without a
-- separate serialization step. The GIN-on-`jsonb_path_ops` index
-- accelerates the v1.1.2 query DSL's equality and containment operators
-- (`docs::find` with `$eq` / `$in`); range/comparison operators rely on
-- the per-collection seq scan within the small `app_id` partition.
--
-- `created_at` / `updated_at` are server-managed: created on insert,
-- bumped on every successful update. The returned doc envelope surfaces
-- both fields to scripts for read-only access (no script-side override).
CREATE TABLE docs (
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
collection TEXT NOT NULL,
id UUID NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (app_id, collection, id)
);
-- The dispatcher/find hot path: "all docs in app X / collection Y."
-- The PK already covers (app_id, collection) as a prefix but spelling
-- out the explicit index makes intent clear for the planner. Mirrors
-- 0007_kv.sql's idx_kv_entries_app_collection.
CREATE INDEX idx_docs_app_collection ON docs (app_id, collection);
-- GIN on JSONB with the `jsonb_path_ops` opclass: smaller index than
-- the default `jsonb_ops`, supports `@>` (containment) which is what
-- equality filters compile to under the GIN-friendly path. Range
-- operators ($gt/$gte/$lt/$lte/$ne) fall back to per-collection scans;
-- those are still bounded by the (app_id, collection) selectivity.
CREATE INDEX idx_docs_data_gin ON docs USING GIN (data jsonb_path_ops);

View File

@@ -0,0 +1,36 @@
-- v1.1.2: Extend the triggers framework to recognise `docs` as the
-- second concrete kind (after `kv` in v1.1.1).
--
-- Two CHECK constraints widen (no narrowing — both lists strictly
-- gain `'docs'`); one new detail table mirrors `kv_trigger_details`'s
-- shape with `DocsEventOp` ops instead of `KvEventOp`. Dispatcher
-- routing is generic across kinds — the same code path that handles
-- `Kv | DeadLetter` outbox rows now also handles `Docs` (single match
-- arm extension on the Rust side; no migration needed).
-- Extend triggers.kind to include 'docs'. Constraint is in-line on the
-- column so Postgres auto-named it `triggers_kind_check`. Dropping the
-- old and adding the widened constraint is safe — no existing rows
-- carry a value outside the new set.
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
CHECK (kind IN ('kv', 'dead_letter', 'docs'));
-- Extend outbox.source_kind to include 'docs'. Same shape as above;
-- v1.1.1's existing source_kinds ('http', 'kv', 'dead_letter') stay.
ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs'));
-- One row per docs trigger. Same shape as `kv_trigger_details`:
-- collection_glob — "*" matches all, "foo*" prefix-matches, "foo"
-- exact-matches (Rust-side via collection_matches).
-- ops — subset of {create, update, delete}. Empty array
-- means "any op" (matches every docs mutation in
-- the collection). The admin endpoint rejects
-- empty collection_glob; ops can be empty.
CREATE TABLE docs_trigger_details (
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
collection_glob TEXT NOT NULL,
ops TEXT[] NOT NULL
);