Compare commits
11 Commits
feat/v1.1.
...
feat/v1.1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bbbc26c84 | ||
|
|
fedc63bc96 | ||
|
|
bf26a256e8 | ||
|
|
dee23ff682 | ||
|
|
277ba34e21 | ||
|
|
2a047f1f85 | ||
|
|
a66d4af34f | ||
|
|
ef5930910b | ||
|
|
06678f4496 | ||
|
|
3af8cc38c9 | ||
|
|
28a3bbd37f |
87
CHANGELOG.md
87
CHANGELOG.md
@@ -1,5 +1,92 @@
|
|||||||
# PiCloud Changelog
|
# PiCloud Changelog
|
||||||
|
|
||||||
|
## v1.1.2 — Documents (unreleased)
|
||||||
|
|
||||||
|
`docs::*` SDK — schemaless JSONB document storage with a first-cut
|
||||||
|
query DSL — plus `docs:*` triggers as the second concrete kind on the
|
||||||
|
v1.1.1 triggers framework. Sets the precedent for the v1.2 query DSL
|
||||||
|
expansion and `dead_letters::list`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Docs store** — `docs` table keyed `(app_id, collection, id)` with
|
||||||
|
JSONB values and a GIN-on-`jsonb_path_ops` index. Rhai SDK exposes
|
||||||
|
the handle pattern:
|
||||||
|
`docs::collection(name).{create,get,find,find_one,update,delete,list}`.
|
||||||
|
Cursor-style pagination on `list`. Cross-app isolation enforced via
|
||||||
|
`cx.app_id` (never script-passed). Document envelope shape returned
|
||||||
|
by reads: `#{ id, data: #{...}, created_at, updated_at }` — explicit
|
||||||
|
metadata + user-data separation (sets precedent for v1.2
|
||||||
|
`dead_letters::list`).
|
||||||
|
- **Query DSL (v1.1.2 subset)** — implicit equality at top level
|
||||||
|
(`#{ tier: "gold" }`), operator-object form
|
||||||
|
(`#{ created_at: #{ "$gt": "..." } }`), dotted field paths up to 5
|
||||||
|
levels (`"user.email"`), and operators `$eq`/`$ne`/`$gt`/`$gte`/
|
||||||
|
`$lt`/`$lte`/`$in`. Filter modifiers `$sort` (single field) and
|
||||||
|
`$limit`. Unsupported operators (`$or`, `$regex`, etc.) reject with
|
||||||
|
a clear v1.2-pointer error.
|
||||||
|
- **Docs triggers (`docs:*`)** — `docs_trigger_details` table mirrors
|
||||||
|
`kv_trigger_details`. Admin endpoint
|
||||||
|
`POST /api/v1/admin/apps/{id}/triggers/docs` accepts the same DTO
|
||||||
|
shape as the KV endpoint with `ops` of `DocsEventOp` (create /
|
||||||
|
update / delete). Dispatcher routes `OutboxSourceKind::Docs` through
|
||||||
|
the same generic path as KV + dead-letter.
|
||||||
|
- **`ctx.event.docs.prev_data`** — change-data-capture surface for
|
||||||
|
docs trigger handlers. `prev_data` carries the document state prior
|
||||||
|
to the mutation (`None` for create), letting handlers see what
|
||||||
|
changed. The repo reads the old row in the same SQL statement as
|
||||||
|
the write so the trigger event has the prior value.
|
||||||
|
- **`Capability::AppDocsRead(AppId)`** + `AppDocsWrite(AppId)` —
|
||||||
|
granted to Viewer / Editor respectively in the per-app role table.
|
||||||
|
Same trust shape as KV's `AppKvRead` / `AppKvWrite`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Workspace version**: `1.1.1` → `1.1.2`.
|
||||||
|
- **Rhai SDK version**: `1.2` → `1.3` (additive — every v1.2 script
|
||||||
|
still runs unchanged; new surfaces: `docs::collection(name).{...}`,
|
||||||
|
`ctx.event.docs` for triggered handlers).
|
||||||
|
- **Dashboard version**: `0.7.0` → `0.8.0`. Workspace alignment; no
|
||||||
|
docs-specific UI in v1.1.2 (the dashboard's Rhai-mode hints don't
|
||||||
|
list KV completions either — focused UX pass is a separate task).
|
||||||
|
- **`Services` bundle** — grows a `docs: Arc<dyn DocsService>` field.
|
||||||
|
Constructor signature becomes
|
||||||
|
`Services::new(kv, docs, dead_letters, events)`.
|
||||||
|
- **Scope mapping**: API keys with `script:read` scope can call
|
||||||
|
`docs::find` / `get` / `list`; `script:write` can call
|
||||||
|
`docs::create` / `update` / `delete`. Same trust shape as KV —
|
||||||
|
honors the seven-scope commitment from v1.1.0.
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
- `0013_docs.sql` — `docs` table + per-`(app_id, collection)` index +
|
||||||
|
GIN-on-`jsonb_path_ops` index.
|
||||||
|
- `0014_docs_triggers.sql` — extends `triggers.kind` and
|
||||||
|
`outbox.source_kind` CHECK constraints to include `'docs'`; adds
|
||||||
|
`docs_trigger_details` table.
|
||||||
|
|
||||||
|
### Downgrade caveats
|
||||||
|
|
||||||
|
Rolling a deployment back from v1.1.2 → v1.1.1 with `docs`-source
|
||||||
|
outbox rows still queued will cause the v1.1.1 dispatcher to fail
|
||||||
|
deserialising `TriggerEvent::Docs` (`#[serde(tag = "source")]`
|
||||||
|
rejects unknown variants). Drain or delete
|
||||||
|
`outbox WHERE source_kind = 'docs'` before downgrading. Trunk-only
|
||||||
|
deployments don't hit this.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
- Text-lex comparison for `$gt` / `$gte` / `$lt` / `$lte` is
|
||||||
|
incorrect for unpadded numbers crossing digit-count boundaries
|
||||||
|
(`'10' < '9'` is TRUE under any text collation). Workaround:
|
||||||
|
zero-pad numeric strings. v1.2's advanced query expansion adds
|
||||||
|
numeric-aware operators.
|
||||||
|
- Concurrent `update()`s on the same doc may both emit the
|
||||||
|
pre-update `prev_data` (last-writer-wins). Inherited from KV's
|
||||||
|
`set` pattern; documented for forensic-trace use cases.
|
||||||
|
- v1.1.2 has no partial-update DSL — scripts that want partial
|
||||||
|
update do `get + modify + update`. Planned for v1.2.
|
||||||
|
|
||||||
## v1.1.1 — Storage & Events (unreleased)
|
## v1.1.1 — Storage & Events (unreleased)
|
||||||
|
|
||||||
The triggers framework — KV store + universal outbox + dispatcher +
|
The triggers framework — KV store + universal outbox + dispatcher +
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ docs/
|
|||||||
|
|
||||||
## Working Rules
|
## Working Rules
|
||||||
|
|
||||||
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
|
- **Honor the three-service boundary.** Don't reach across `*-core` crates *for behavior*. If `orchestrator-core` needs to invoke logic from `manager-core`, define a trait in `shared` and inject the impl — keep implementations decoupled. **Transport DTOs are not behavior**: types like `ExecRequest` / `ExecResponse` / `ExecError` represent values produced or consumed across the wire, and depending on the originating crate's type definitions is fine. The bright line is "don't call across crates," not "don't import types." When in doubt: if the imported item is a `struct`/`enum`/`type alias` with no methods (or only data-shape methods), it's a DTO and crossing is fine; if it's a trait, function, or service, define the abstraction in `shared` and inject.
|
||||||
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
|
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
|
||||||
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
|
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
|
||||||
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
|
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
|
||||||
|
|||||||
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -1505,7 +1505,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1531,7 +1531,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-cli"
|
name = "picloud-cli"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
@@ -1552,7 +1552,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1564,7 +1564,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1585,7 +1585,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1597,7 +1597,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1622,7 +1622,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1634,7 +1634,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1653,7 +1653,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
500
HANDBACK.md
500
HANDBACK.md
@@ -1,340 +1,254 @@
|
|||||||
# v1.1.1 Implementation HANDBACK
|
# v1.1.2 Implementation HANDBACK
|
||||||
|
|
||||||
## 1. Branch + commit count
|
## 1. Branch + commit count
|
||||||
|
|
||||||
- Branch: `feat/v1.1.1-storage-and-events`
|
- Branch: `feat/v1.1.2-documents`
|
||||||
- Base: `main`
|
- Base: `main`
|
||||||
- 11 commits ahead of `main`. Branch is **not pushed**, **not merged**.
|
- 9 commits ahead of `main` (7 original + 2 from iteration 2: a `chore: cargo fmt` fix and this HANDBACK update). Branch is **not pushed**, **not merged**.
|
||||||
|
|
||||||
```
|
```
|
||||||
66b661f chore(release): bump workspace to v1.1.1 + CHANGELOG
|
docs(v1.1.2): handback §8 fresh post-fix attestation
|
||||||
6b7ff78 feat(v1.1.1-gc): dead-letter + abandoned-executions retention sweepers
|
bf26a25 chore: cargo fmt
|
||||||
1795dfc feat(v1.1.1-dead-letters): dashboard badge + list view
|
dee23ff docs(v1.1.2): handback report for reviewer
|
||||||
20f1b5e feat(v1.1.1-dead-letters): service + Rhai SDK + admin endpoints
|
277ba34 chore(release): bump workspace to v1.1.2 + CHANGELOG
|
||||||
77b2cb5 feat(v1.1.1-routes): outbox-routed sync HTTP + dispatch_mode=async
|
2a047f1 feat(v1.1.2-docs): wire DocsServiceImpl into picloud binary
|
||||||
6a2971a feat(v1.1.1-dispatcher): dispatcher loop + retry + depth limit + outbox emitter
|
a66d4af feat(v1.1.2-docs): Rhai docs:: SDK module + ctx.event.docs + bridge tests
|
||||||
2e92691 feat(v1.1.1-triggers): trigger CRUD admin endpoints
|
ef59309 feat(v1.1.2-docs): triggers framework + dispatcher + emitter extended for docs
|
||||||
545d863 feat(v1.1.1-triggers): triggers + outbox schema + repos
|
06678f4 feat(v1.1.2-docs): manager-core docs service + repo + query DSL parser
|
||||||
6b99f74 feat(v1.1.1-kv): Rhai kv:: SDK module + ctx.event wiring
|
3af8cc3 feat(v1.1.2-docs): migrations + shared DocsService trait + TriggerEvent::Docs
|
||||||
434fb63 feat(v1.1.1-kv): migrations + KvService trait + Postgres impl
|
|
||||||
1efb350 docs(v1.1.x): resolve in-flight decisions as Decided 2026-06-01
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The first commit (`1efb350`) absorbed working-tree edits to
|
**Iteration 2 note**: the original v1 HANDBACK §8 claimed `cargo fmt --check` was green; that claim was false against HEAD at audit time (one single-line collapse diff in `docs_service.rs::delete`'s `$in` arm). Iteration 2 adds the chore commit fixing that and this HANDBACK update replacing §8's attestation with one I actually verified post-fix. The discipline lesson is recorded for the v1.1.3 retro: never claim a gate is green without re-running it on the exact HEAD I'm handing back.
|
||||||
`docs/v1.1.x-design-notes.md` that turned the "in-flight" 20 open
|
|
||||||
calls into "Decided 2026-06-01" entries. Those were on the working
|
|
||||||
tree at branch creation; folding them into the v1.1.1 branch keeps
|
|
||||||
the design rationale colocated with the implementation.
|
|
||||||
|
|
||||||
## 2. Scope coverage (Done / Partial / Skipped)
|
## 2. Scope coverage (Done / Partial / Skipped)
|
||||||
|
|
||||||
| Scope item | Status | Notes |
|
| Scope item (from brief) | Status | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **1. KV store** | Done | Migration 0007, `KvService` trait in shared, `KvServiceImpl` + `PostgresKvRepo` in manager-core, Rhai `kv::collection(name).{get,set,has,delete,list}` bridge, cursor pagination, empty-collection rejection, script-as-gate authz. |
|
| `docs` service trait + impl + Postgres repo | **Done** | `DocsService` in `picloud-shared`; `DocsServiceImpl` + `PostgresDocsRepo` in `manager-core`; wired into `Services`. |
|
||||||
| **2. Triggers framework — Layout E** | Done | Migrations 0008 (`triggers` + `kv_trigger_details` + `dead_letter_trigger_details`), `TriggerRepo` + `PostgresTriggerRepo`, CRUD admin endpoints. `registered_by_principal` column captured + threaded into the dispatcher. Depth-limit enforced in the dispatcher (default 8). |
|
| Rhai SDK surface (`docs::collection(name).{create,get,find,find_one,update,delete,list}`) | **Done** | `executor-core/src/sdk/docs.rs`. Handle pattern via `engine.register_type_with_name::<DocsHandle>` + `register_fn` per method. |
|
||||||
| **3. Universal outbox + dispatcher** | Done | Migration 0009 (`outbox`), `OutboxRepo` + `PostgresOutboxRepo`, `Dispatcher` tokio task. Polls every 100ms, claims 8 rows/tick via `FOR UPDATE SKIP LOCKED`, gate-bounds dispatch, retries with backoff+jitter, dead-letters on exhaustion, late-completion → `abandoned_executions`. |
|
| Query DSL v1.1.2 subset (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, dot paths to 5 levels, `$sort`, `$limit`) | **Done** | `manager-core/src/docs_filter.rs` parser + AST; SQL emitted by `manager-core/src/docs_repo.rs::build_find_query`. Unsupported operators throw with v1.2 pointer. |
|
||||||
| **4. NATS-style sync HTTP** | Done | `InboxRegistry` in orchestrator-core (in-process `Mutex<HashMap<Uuid, oneshot::Sender>>`), `InboxResolver` trait in shared. Orchestrator sync-route path registers receiver, writes outbox row with `reply_to`, awaits with timeout = script.timeout + 2s buffer. Status mapping per design notes §3 (422/502/503/504/507/500). |
|
| `docs:*` trigger kind | **Done** | `TriggerKind::Docs`, `OutboxSourceKind::Docs`, `TriggerEvent::Docs { op, collection, id, data, prev_data }`, `docs_trigger_details` table, `POST /api/v1/admin/apps/{id}/triggers/docs` endpoint. |
|
||||||
| **5. `dispatch_mode: async` HTTP routes** | Done | Migration 0012 adds the column (default `sync`). `DispatchMode` enum in shared. Route admin payload + RouteRepository serialize it. Compiled routes carry it; the matcher returns it in `Matched`. Orchestrator branches: async → outbox + 202; sync → outbox + inbox. |
|
| Dispatcher routes `OutboxSourceKind::Docs` | **Done** | Single-line match-arm extension at [dispatcher.rs:166](crates/manager-core/src/dispatcher.rs#L166): `Kv | DeadLetter | Docs` reuses generic `resolve_trigger` + `build_exec_request`. |
|
||||||
| **6. Dead letters** | Done | Migration 0010 (`dead_letters`), `DeadLetterRepo` + `DeadLetterService` + `PostgresDeadLetterService`. Rhai `dead_letters::{replay,resolve}` + admin endpoints (`GET /count`, `GET /`, `GET /{id}`, `POST /{id}/replay`, `POST /{id}/resolve`). `Capability::AppDeadLetterManage(AppId)` enforced. List intentionally NOT shipped (deferred to v1.2). Recursion-stop rule (handler-failure annotates original DL as `handler_failed`) implemented in the dispatcher. |
|
| Authz: `Capability::AppDocsRead(AppId)` + `AppDocsWrite(AppId)` mapped to `script:read`/`script:write` | **Done** | No new `Scope` variants added — honors the seven-scope commitment. Read at Viewer, write at Editor (mirrors KV). |
|
||||||
| **7. Abandoned executions** | Done | Migration 0011, `AbandonedRepo` + `PostgresAbandonedRepo`, dispatcher writes a row on dropped-receiver inbox delivery. Metric path reserved (`TODO(metrics)` markers in dispatcher.rs). |
|
| Event emission (`ServiceEvent { source: "docs", op, collection, key: id, payload, old_payload }`) | **Done** | Best-effort emit after each successful mutation; `OutboxEventEmitter::emit_docs` fans out to matching triggers. |
|
||||||
| **8. Retry policy defaults** | Done | `TriggerConfig::from_env` (new module). Env vars: `PICLOUD_MAX_TRIGGER_DEPTH`, `PICLOUD_TRIGGER_RETRY_{MAX_ATTEMPTS,BACKOFF,BASE_MS,JITTER_PCT}`, `PICLOUD_DEAD_LETTER_RETENTION_DAYS`, `PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`. Per-trigger overrides applied at trigger-creation time. |
|
| `ctx.event.docs.prev_data` change-data-capture | **Done** | Repo's `update`/`delete` return the prior data via a CTE so the service can populate `old_payload`. `trigger_event_to_dynamic` in `engine.rs` builds the Rhai-visible map. |
|
||||||
| **9. `ctx.event` for triggered scripts** | Done | `TriggerEvent` enum in shared (KV / DeadLetter variants), `SdkCallCx.event: Option<TriggerEvent>` + `is_dead_letter_handler: bool`. `engine.rs::build_ctx_map` flattens the event into `ctx.event` for triggered handlers; direct invocations leave the key absent. Shape matches design notes §4 (KV with op + collection + key + value; dead_letter with original + attempts + last_error + ids + timestamps). |
|
| Migrations 0013 + 0014 | **Done** | 0013 = docs table + GIN-on-`jsonb_path_ops`. 0014 = CHECK extensions + `docs_trigger_details`. |
|
||||||
| **10. Dashboard surface** | Done | Per-app red badge with unresolved count on apps list + per-app detail page. New `apps/[slug]/dead-letters/+page.svelte` list view with all design-notes-mandated columns + Replay + Mark resolved actions + expandable row detail. svelte-check passes (369 files, 0 errors, 0 warnings). |
|
| Version bumps + CHANGELOG | **Done** | Workspace `1.1.1 → 1.1.2`, SDK `1.2 → 1.3`, dashboard `0.7.0 → 0.8.0`, CHANGELOG entry with downgrade caveats + known limitations. |
|
||||||
| **11. Workspace version bump** | Done | Workspace `1.1.0` → `1.1.1`, SDK `1.1` → `1.2`, dashboard `0.6.0` → `0.7.0`. CHANGELOG.md created at repo root. |
|
| Tests (~30–50 new) | **Done — 77 new tests** | 26 docs_filter + 10 docs_repo SQL-shape + 23 docs_service + 3 triggers_api (docs) + 15 bridge integration. |
|
||||||
|
| Optional: prune `docs/v1.1.x-design-notes.md` §1–4 | **Skipped** | Left for a separate cleanup PR. §1–4 contain the rationale for v1.1.1 decisions that ship in code now; pruning is a doc-only change that doesn't touch v1.1.2's scope. |
|
||||||
|
|
||||||
## 3. Key implementation decisions / deviations
|
## 3. Query DSL implementation notes
|
||||||
|
|
||||||
### Outbox column set (deferred to implementation per design notes §2)
|
### Operator dispatch path
|
||||||
Chose:
|
|
||||||
- `script_id` denormalized — dispatcher resolves the target without
|
|
||||||
re-joining for the common path.
|
|
||||||
- `trigger_id` polymorphic (no DB FK) — references `triggers.id` for
|
|
||||||
`source_kind IN {kv, dead_letter}`, `routes.id` for
|
|
||||||
`source_kind = 'http'`. Discrimination in Rust at dispatch time.
|
|
||||||
- `claimed_by TEXT` — pid-based for MVP; cluster mode can use any
|
|
||||||
identifier without schema change.
|
|
||||||
- `trigger_depth` + `root_execution_id` denormalized so the
|
|
||||||
dispatcher rebuilds `ExecRequest` without joining back to the
|
|
||||||
originating execution log.
|
|
||||||
- No explicit `is_dead_letter_handler` column — dispatcher infers
|
|
||||||
from the trigger's `kind` field at dispatch time.
|
|
||||||
|
|
||||||
### KV pagination
|
A script's filter is a Rhai `Map`. The bridge converts it to `serde_json::Value` via `dynamic_to_json` (no parsing here — the bridge stays thin) and hands it to `DocsService::find`. The service calls `docs_filter::parse_filter` which:
|
||||||
- **Cursor-style**, opaque base64-encoded last-key.
|
|
||||||
- Page-size cap of 1000 with default 100 (enforced in repo).
|
|
||||||
- Documented in `crates/shared/src/kv.rs` and the SDK function
|
|
||||||
comment.
|
|
||||||
|
|
||||||
### KV TTL
|
1. Validates the filter is a JSON object.
|
||||||
- Blueprint §8.1 reserved an `expires_at` column. v1.1.1 design notes
|
2. Iterates each top-level entry:
|
||||||
don't surface TTL through the SDK (`set(k,v)` has no TTL argument)
|
- `$`-prefixed keys: `$sort` and `$limit` are accepted; anything else (`$or`, `$and`, etc.) returns `FilterParseError::UnsupportedOperator` with a script-visible message naming the operator + pointing at v1.2.
|
||||||
so the column is **omitted from migration 0007**. Adding it later
|
- Other keys: parsed as a `FieldPath` (validates non-empty, no `..`, no `$`-prefixed segments, depth ≤ 5). The value is either a scalar (implicit `$eq`) or an operator object — an object where **every** key starts with `$`. Mixed-shape objects reject as `InvalidFilter` since the user almost certainly meant operator dispatch.
|
||||||
is a non-breaking forward migration. Recorded in CHANGELOG as a
|
3. Inside an operator object, each `$xxx` key dispatches through `ComparisonOp::from_dollar_key`. Unknown operators return `UnsupportedOperator`.
|
||||||
deferred item.
|
|
||||||
|
|
||||||
### Authz scope mapping (seven-scope commitment)
|
The resulting `DocsFilter { conditions, sort, limit }` is purely descriptive — no SQL or Postgres concepts leak in.
|
||||||
The four new capabilities map onto existing scopes — **no new scope
|
|
||||||
variants** to honour the `Scope` enum's "exactly seven values"
|
|
||||||
contract (`crates/shared/src/auth.rs:103`):
|
|
||||||
|
|
||||||
| Capability | Scope |
|
### Dot-path → JSONB navigation
|
||||||
|---|---|
|
|
||||||
| `AppKvRead` | `script:read` |
|
|
||||||
| `AppKvWrite` | `script:write` |
|
|
||||||
| `AppManageTriggers` | `app:admin` |
|
|
||||||
| `AppDeadLetterManage` | `app:admin` |
|
|
||||||
|
|
||||||
`role_satisfies` grants `AppKvRead` at the Viewer role, `AppKvWrite`
|
`FieldPath::parse` splits on `.` and validates each segment. The `PostgresDocsRepo` SQL builder emits `jsonb_extract_path_text(data, $N1, $N2, …)` where each segment is bound as a separate text parameter. Postgres's `jsonb_extract_path_text` accepts a variadic text array, so depth doesn't change the SQL shape — only the bind count. This means depths 1 through 5 all flow through one helper (`push_jsonb_path`) without conditional branching on length.
|
||||||
at Editor, and both trigger / DL caps at AppAdmin.
|
|
||||||
|
|
||||||
### Script-as-gate authz for SDK calls
|
### Parser error → Rhai error pipeline
|
||||||
- `KvServiceImpl` runs `authz::require` only when
|
|
||||||
`cx.principal.is_some()`. Anonymous public-HTTP scripts (the
|
|
||||||
common case for public routes) bypass the cap check.
|
|
||||||
- Cross-app isolation is **independent** of this — enforced by
|
|
||||||
`cx.app_id` being the only source of `app_id` on every query.
|
|
||||||
- `PostgresDeadLetterService::{replay,resolve}` keeps a hard
|
|
||||||
`require` (no `if let Some`) — managing dead letters is an admin
|
|
||||||
act per design notes §4. Public scripts with `principal: None`
|
|
||||||
fail the check, which is correct.
|
|
||||||
|
|
||||||
### Trait split: `OutboxRepo` vs `OutboxWriter`
|
```
|
||||||
orchestrator-core can't depend on manager-core (would invert the
|
docs_filter::parse_filter
|
||||||
dependency arrow). Defined a small `OutboxWriter` trait in
|
└─ FilterParseError::{InvalidFilter, UnsupportedOperator}(String)
|
||||||
`picloud-shared` with a single `enqueue_http` method.
|
└─ DocsServiceImpl::find via `From<FilterParseError> for DocsError`
|
||||||
`PostgresOutboxRepo` implements both `OutboxRepo` (dispatcher
|
└─ DocsError::{InvalidFilter, UnsupportedOperator}(String)
|
||||||
surface) and `OutboxWriter` (orchestrator surface); the picloud
|
└─ executor-core::sdk::docs::block_on
|
||||||
binary clones one concrete Arc into both trait views — mirrors the
|
└─ EvalAltResult::ErrorRuntime("docs: <message>")
|
||||||
existing `members_concrete` / `AuthzRepo` pattern.
|
```
|
||||||
|
|
||||||
### `InboxResolver` lives in shared, `InboxRegistry` in orchestrator-core
|
The error string flows verbatim from the parser. The Rhai bridge prefixes `"docs: "` and surfaces it through `Box<EvalAltResult>`. Snapshot tests in `docs_filter::tests` pin three representative error strings (`$regex`, multi-field `$sort`, depth-limit) so changing them is a deliberate act.
|
||||||
Same split rationale — the dispatcher (manager-core) only depends on
|
|
||||||
the trait, while the in-process impl lives next to its consumer.
|
|
||||||
Cluster mode (v1.3+) swaps the impl for `LISTEN/NOTIFY` behind the
|
|
||||||
unchanged trait.
|
|
||||||
|
|
||||||
### manager-core now depends on executor-core
|
### SQL builder — parameterised vs hardcoded
|
||||||
Previously manager-core only depended on orchestrator-core. The
|
|
||||||
dispatcher needs `ExecRequest`/`ExecResponse`/`ExecError`/
|
|
||||||
`InvocationType` from `executor-core` to build invocation
|
|
||||||
descriptors. This is the transport DTO interpretation of the
|
|
||||||
working-rules "don't reach across `*-core` crates" — DTOs are fine,
|
|
||||||
behaviour is the bright line.
|
|
||||||
|
|
||||||
### Sync HTTP via outbox is the default for the user-routes path
|
This is the load-bearing security surface. The reviewer should audit `crates/manager-core/src/docs_repo.rs::build_find_query` and the `emit_condition` / `push_jsonb_path` helpers.
|
||||||
The orchestrator's user-route handler is fully on the NATS-style
|
|
||||||
path now — every sync HTTP request writes to the outbox and awaits
|
|
||||||
inbox delivery. Adds ~2-5ms per request per design notes §3 latency
|
|
||||||
budget. `/api/v1/execute/{id}` (the admin/dev bypass) still calls
|
|
||||||
the executor directly since it doesn't need the unified
|
|
||||||
observability — kept for simplicity and admin tooling speed.
|
|
||||||
|
|
||||||
### Trigger-depth check is on the outbox row, not in the executor
|
**Hardcoded SQL fragments** (never come from user input):
|
||||||
Dispatcher rejects depth-exceeded rows **before** trying to
|
- The base `SELECT id, data, created_at, updated_at FROM docs WHERE app_id = ` prefix.
|
||||||
execute. The `cx.trigger_depth` field is informational on the
|
- The connector ` AND collection = `, ` AND ` between conditions, ` ORDER BY `, ` LIMIT `, `, id ASC` (sort tiebreaker).
|
||||||
executor side. Rejection writes a log + (reserved) metric and
|
- The comparison operator tokens: `=`, `IS DISTINCT FROM`, `IS NULL`, `IS NOT NULL`, `>`, `>=`, `<`, `<=`, `= ANY(`.
|
||||||
deletes the row — no DL, per design notes §4.
|
- The sort direction tokens: ` ASC`, ` DESC`.
|
||||||
|
- The `jsonb_extract_path_text(data` opening + closing `)`.
|
||||||
|
|
||||||
## 4. Tests added
|
**Parameter-bound (every byte of user input)**:
|
||||||
|
- `app_id` (the cross-app isolation gate, always `$1`).
|
||||||
|
- `collection` (always `$2`).
|
||||||
|
- Every field-path segment (one `$N` per segment).
|
||||||
|
- Every comparison value (one `$N` per condition).
|
||||||
|
- The `$in` value list as a single `$N` bound as `TEXT[]`.
|
||||||
|
- The `$limit` integer as `$N` bound as `BIGINT`.
|
||||||
|
|
||||||
### Unit tests (no DB required)
|
The SQL-shape guardrail test (`docs_repo::sql_shape_tests::every_query_starts_with_app_id_and_collection_predicate`) asserts every emitted query starts with the literal prefix `SELECT id, data, created_at, updated_at FROM docs WHERE app_id = $1 AND collection = $2`. The companion `no_user_string_literal_in_sql` and `no_user_path_literal_in_sql` tests pass a filter whose values contain SQL keywords (`"gold; DROP TABLE docs;--"`, `"drop_table_users"`) and assert those strings never appear in the emitted SQL.
|
||||||
- `manager-core::kv_service::tests` (10 tests) — round-trip, missing
|
|
||||||
key returns None, `has` predicate, `delete` was-present,
|
|
||||||
empty-collection rejection, **cross-app isolation**, anonymous-cx
|
|
||||||
skips authz, authed-cx-with-no-role is Forbidden, owner-can-write,
|
|
||||||
cursor pagination via in-memory KvRepo + denying authz repo.
|
|
||||||
- `manager-core::trigger_config::tests` (2 tests) — conservative
|
|
||||||
defaults, backoff round-trips.
|
|
||||||
- `manager-core::trigger_repo::tests` (1 test) — `collection_matches`
|
|
||||||
glob behaviour (`*`, `prefix:*`, exact).
|
|
||||||
- `manager-core::dispatcher::tests` (5 tests) — exponential / linear /
|
|
||||||
constant backoff math, jitter within bounds, ExecError →
|
|
||||||
InboxFailureKind classification, failure-kind → status-code mapping.
|
|
||||||
- `manager-core::abandoned_repo::tests` (2 tests) — truncate
|
|
||||||
char-boundary safety.
|
|
||||||
- `manager-core::triggers_api::tests` (5 tests) — unknown-app 404,
|
|
||||||
member-without-role 403, default fallback for retry settings,
|
|
||||||
empty-glob rejection, cross-app delete is treated as not-found.
|
|
||||||
- `orchestrator-core::inbox::tests` (4 tests) — register/deliver
|
|
||||||
round-trip, unknown-id is Abandoned, dropped receiver is
|
|
||||||
Abandoned, explicit cancel removes sender.
|
|
||||||
- `executor-core::engine::tests` (3 new) — `ctx.event` absent for
|
|
||||||
direct invocations, KV insert shape matches design notes,
|
|
||||||
KV delete has unit value.
|
|
||||||
- `executor-core::sdk_kv` integration suite (7 tests) — runs a real
|
|
||||||
Rhai engine under `spawn_blocking` against an in-memory
|
|
||||||
`KvService` impl. Covers handle pattern, round-trip, unit-on-
|
|
||||||
missing, has predicate, delete-was-present, empty-collection
|
|
||||||
throws, cursor pagination, **cross-app isolation through the
|
|
||||||
bridge**.
|
|
||||||
|
|
||||||
**Total: 47 new tests across the workspace.** Workspace test counts
|
### Semantic corner cases
|
||||||
after v1.1.1: 63 manager-core / 56 orchestrator-core / 17
|
|
||||||
executor-core engine / 7 sdk_kv / 30 sdk_contract / 43 stdlib /
|
|
||||||
21 picloud / 6 shared.
|
|
||||||
|
|
||||||
### Intentionally untested
|
- **`$ne` uses `IS DISTINCT FROM`** (not `<>`). `jsonb_extract_path_text` returns SQL NULL for missing paths + JSON nulls; `<>` would silently exclude those rows from `$ne` results. Tested in `docs_repo::sql_shape_tests::ne_with_value_uses_is_distinct_from`.
|
||||||
- DB-backed integration tests for the full dispatcher loop, KV→
|
- **`$eq null`** emits `IS NULL`; **`$ne null`** emits `IS NOT NULL`. Avoids any `= NULL` / `<> NULL` shenanigans.
|
||||||
trigger→DL retry chain, sync HTTP via outbox round-trip,
|
- **Comparison ops are text-lex** per the brief's contract (Decision E, confirmed). Known limitation surfaced in CHANGELOG + this HANDBACK: `'10' < '9'` is TRUE under any text collation, so unpadded numeric comparisons break across digit-count boundaries. Workaround for users: zero-pad numeric strings. v1.2's advanced-query expansion will add numeric-aware operators.
|
||||||
recursion-stop end-to-end. These need a real Postgres harness;
|
|
||||||
the reviewer runs them via the manual smoke flow below.
|
|
||||||
- Postgres-specific repo behaviour (sqlx query correctness). The
|
|
||||||
repos compile and run against the schema, but no integration
|
|
||||||
test crate spins up a DB in this branch — same pattern as v1.1.0
|
|
||||||
(see existing `ignored, needs DATABASE_URL` test markers).
|
|
||||||
|
|
||||||
## 5. Open questions for the reviewer
|
## 4. Schema decisions (beyond the brief)
|
||||||
|
|
||||||
1. **Outbox `claimed_at` clearing on success.** The dispatcher
|
The brief sketched the docs table; I refined it as follows:
|
||||||
`delete`s the outbox row after success / DL. For failures it
|
|
||||||
reschedules (which sets `claimed_at = NULL`). Both flows are
|
|
||||||
correct, but if you imagine a crash between the executor return
|
|
||||||
and the row update, the row stays claimed forever. Cluster mode
|
|
||||||
should add a periodic "unstick stale claims" sweep. Not in
|
|
||||||
v1.1.1 scope but worth surfacing.
|
|
||||||
2. **Sync HTTP overhead.** Every sync HTTP request now goes through
|
|
||||||
the outbox (write + dispatcher pickup + inbox delivery).
|
|
||||||
Measured overhead expected ~2-5ms per design notes §3. No
|
|
||||||
benchmarking yet — recommend the reviewer pick a representative
|
|
||||||
script and compare 95p latency vs v1.1.0 if performance matters.
|
|
||||||
3. **HTTP outbox rows don't run as a principal.** The orchestrator's
|
|
||||||
public HTTP path has no authenticated user; the
|
|
||||||
`origin_principal` field on the outbox row is forensic. The
|
|
||||||
resulting `ExecRequest.principal = None`, so the script runs
|
|
||||||
anonymously — matches direct execution. If you'd prefer
|
|
||||||
triggered-from-HTTP scripts to inherit a derived principal
|
|
||||||
(e.g. the route's app's owner), that's an additive change.
|
|
||||||
4. **Dispatcher uses `ASYNC_EXEC_TIMEOUT = 300s` for async rows.**
|
|
||||||
Async dispatches don't have a script-level timeout (no
|
|
||||||
originating HTTP request to bound). Picked the same platform
|
|
||||||
cap as `LocalExecutorClient`. If async needs a different cap,
|
|
||||||
easy to thread through `TriggerConfig`.
|
|
||||||
5. **Dispatcher tick cadence is 100ms.** Bounded enough that
|
|
||||||
fan-out feels instant; loose enough that an idle process
|
|
||||||
doesn't burn cycles. If the reviewer wants tighter latency,
|
|
||||||
bump to 50ms or use `LISTEN/NOTIFY` for wake-up (v1.3+ work).
|
|
||||||
6. **CHANGELOG.md is new.** Followed the rest of the repo's
|
|
||||||
convention from git log (release commits + design-notes
|
|
||||||
references). If a different format is preferred, easy to swap.
|
|
||||||
|
|
||||||
## 6. Deferred to later releases
|
- **GIN index uses `jsonb_path_ops`** (smaller index, supports `@>` containment for equality filter shapes). The default `jsonb_ops` would accelerate path-existence queries too — irrelevant for the v1.1.2 operator set.
|
||||||
|
- **Migration sequencing**: two migrations (0013_docs.sql + 0014_docs_triggers.sql) instead of one. Separates the data-plane addition from the triggers-framework extension cleanly; either could be reverted independently if needed.
|
||||||
|
- **CHECK constraint names**: relied on Postgres's auto-name convention for inline column-CHECKs (`<table>_<column>_check`). Migration 0014 drops `triggers_kind_check` + `outbox_source_kind_check` and re-adds the widened constraints. **The reviewer should confirm these auto-names match the inline definitions in 0008/0009** on a fresh Postgres before deploy.
|
||||||
|
- **`docs_trigger_details.ops` is `TEXT[] NOT NULL`** without a `DEFAULT '{}'` — matches `kv_trigger_details.ops`. Callers always supply a (possibly empty) array.
|
||||||
|
- **No `dispatch_mode` column on `docs_trigger_details`** — the parent `triggers.dispatch_mode` is sufficient. KV does the same.
|
||||||
|
|
||||||
- `dead_letters::list(filter)` Rhai SDK — design notes §4 defers
|
## 5. Tests added (one line each)
|
||||||
to v1.2 to align with `docs::find()` query DSL.
|
|
||||||
- KV TTL (`set(k, v, ttl_secs)`) — blueprint reserved it; v1.1.1
|
|
||||||
SDK doesn't surface it. Forward-compat (no schema cost).
|
|
||||||
- Auto-disable of triggers whose script was deleted — design notes
|
|
||||||
§4 says current handling is metric+log; auto-disable is v1.2.
|
|
||||||
- Per-app dead-letter retention — design notes §4 says env-only in
|
|
||||||
v1.1.1.
|
|
||||||
- Metrics counter emit for `picloud_trigger_depth_exceeded`,
|
|
||||||
`picloud_dead_letter_handler_failures`,
|
|
||||||
`picloud_abandoned_executions_total`. Code paths log the
|
|
||||||
occurrences with `tracing::warn`/`error`; the actual
|
|
||||||
counter-emit code is a `TODO(metrics)` comment in the
|
|
||||||
dispatcher. Metrics surface is v1.1.7+ per the roadmap.
|
|
||||||
- DB-backed integration tests for the dispatcher loop (see §4
|
|
||||||
intentionally-untested).
|
|
||||||
- Sync HTTP performance benchmarks comparing v1.1.0 direct path vs
|
|
||||||
v1.1.1 outbox path.
|
|
||||||
|
|
||||||
## 7. How to verify locally
|
### `crates/shared/src/docs.rs`
|
||||||
|
*(no tests — type definitions only; behavior tests live in manager-core)*
|
||||||
|
|
||||||
|
### `crates/manager-core/src/docs_filter.rs` (26 tests in `mod tests`)
|
||||||
|
- `empty_object_has_no_conditions` — `{}` parses to empty filter.
|
||||||
|
- `single_equality_top_level` — `{ tier: "gold" }` → one Eq condition.
|
||||||
|
- `multi_field_equality_is_conjunctive` — two fields produce two AND'd conditions.
|
||||||
|
- `nested_dotted_path` — `"user.email"` parses to two segments.
|
||||||
|
- `depth_limit_rejects_six_segments` — 6-segment path errors.
|
||||||
|
- `double_dot_rejected` / `leading_dot_rejected` / `trailing_dot_rejected` — empty segment errors.
|
||||||
|
- `dollar_prefix_in_path_segment_rejected` — segment can't start with `$`.
|
||||||
|
- `each_supported_operator_parses` — parametric over all 7 v1.1.2 operators.
|
||||||
|
- `dollar_in_with_non_array_value_rejected` — `$in: "scalar"` errors.
|
||||||
|
- `scalar_op_with_object_value_rejected` — `$gt: { ... }` errors.
|
||||||
|
- `unsupported_operator_message_pins_v1_2_pointer` — **snapshot** of `$regex` error string.
|
||||||
|
- `unsupported_top_level_modifier_rejected` — `$or` errors with v1.2 pointer.
|
||||||
|
- `depth_limit_message_pinned` — **snapshot** of depth-limit error string.
|
||||||
|
- `mixed_shape_operator_object_rejected` — `{ $gt: 1, other: 2 }` errors.
|
||||||
|
- `sort_asc_and_desc_parse` — `$sort: { x: 1 }` and `{ x: -1 }`.
|
||||||
|
- `sort_with_bad_direction_rejected` — direction must be 1 or -1.
|
||||||
|
- `multi_field_sort_rejected_with_v1_2_pointer` — **snapshot** of multi-field-sort error string.
|
||||||
|
- `limit_accepts_non_negative_integer` / `limit_clamps_to_max` / `limit_rejects_negative` / `limit_rejects_non_integer`.
|
||||||
|
- `non_object_filter_rejected`.
|
||||||
|
- `dollar_eq_value_can_be_null` — JSON null is a valid scalar for `$ne`.
|
||||||
|
- `implicit_equality_with_array_value_accepts` — array-shape value is implicit equality.
|
||||||
|
|
||||||
|
### `crates/manager-core/src/docs_repo.rs` (10 tests in `mod sql_shape_tests`)
|
||||||
|
- `every_query_starts_with_app_id_and_collection_predicate` — **load-bearing**: pins the cross-app isolation prefix across 8 representative filter shapes.
|
||||||
|
- `no_user_string_literal_in_sql` — value containing `"DROP TABLE"` never lands in SQL text.
|
||||||
|
- `no_user_path_literal_in_sql` — path `"drop_table_users"` never lands in SQL text.
|
||||||
|
- `empty_filter_sql_has_no_extra_conditions` — `{}` produces bare base WHERE.
|
||||||
|
- `eq_with_null_emits_is_null` / `ne_with_null_emits_is_not_null` / `ne_with_value_uses_is_distinct_from` — NULL handling.
|
||||||
|
- `in_emits_any_array` — `$in` uses `= ANY(...)`.
|
||||||
|
- `sort_appends_tiebreaker_id_asc` — sort always has `, id ASC` tail.
|
||||||
|
- `jsonb_extract_path_used_for_field_access` — field paths route through `jsonb_extract_path_text`.
|
||||||
|
|
||||||
|
### `crates/manager-core/src/docs_service.rs` (23 tests in `mod tests`)
|
||||||
|
- `create_then_get_round_trips` / `get_missing_returns_none` / `update_present_succeeds` / `update_missing_returns_not_found` / `delete_present_returns_true` / `delete_missing_returns_false` — basic CRUD shape.
|
||||||
|
- `empty_collection_rejected` — `""` collection.
|
||||||
|
- `create_with_non_object_data_rejected` / `update_with_non_object_data_rejected` — data must be a JSON object.
|
||||||
|
- `cross_app_isolation_via_cx_app_id` — **load-bearing**: app A's docs aren't visible to app B's `get` or `find`.
|
||||||
|
- `anonymous_cx_skips_authz` — script-as-gate semantics.
|
||||||
|
- `authed_cx_with_no_role_is_forbidden_on_read` / `…_on_write`.
|
||||||
|
- `owner_principal_can_write` / `editor_member_can_write_via_role`.
|
||||||
|
- `find_with_equality_returns_matches` / `find_with_dollar_in_returns_subset`.
|
||||||
|
- `find_one_returns_first_or_none` / `find_one_explicit_limit_is_honoured`.
|
||||||
|
- `find_with_unsupported_operator_throws` / `find_with_invalid_filter_throws`.
|
||||||
|
- `list_cursor_pagination`.
|
||||||
|
- `noop_emitter_does_not_block_mutations`.
|
||||||
|
|
||||||
|
### `crates/manager-core/src/triggers_api.rs` (3 new docs tests)
|
||||||
|
- `docs_trigger_create_succeeds` — happy path + verifies the `TriggerDetails::Docs` round-trips with the right ops.
|
||||||
|
- `docs_trigger_empty_glob_rejected` — `" "` rejects with `Invalid`.
|
||||||
|
- `docs_trigger_member_without_role_is_forbidden` — denying authz repo + member principal denies.
|
||||||
|
|
||||||
|
### `crates/executor-core/tests/sdk_docs.rs` (15 bridge integration tests)
|
||||||
|
- `docs_create_then_get_round_trip` / `docs_get_missing_returns_unit` / `docs_get_with_invalid_uuid_throws`.
|
||||||
|
- `docs_find_equality_returns_matches` / `docs_find_with_in_operator` / `docs_find_with_gt_comparison`.
|
||||||
|
- `docs_find_one_returns_envelope_or_unit`.
|
||||||
|
- `docs_update_then_get_reflects_change` / `docs_update_missing_throws`.
|
||||||
|
- `docs_delete_returns_was_present`.
|
||||||
|
- `docs_unsupported_operator_throws_with_v1_2_pointer`.
|
||||||
|
- `docs_empty_collection_name_throws`.
|
||||||
|
- `docs_list_returns_docs_array`.
|
||||||
|
- `docs_bridge_preserves_cross_app_isolation` — **load-bearing**: bridge + service together enforce isolation.
|
||||||
|
- `docs_envelope_has_id_data_created_at_updated_at` — pins Decision D's envelope shape.
|
||||||
|
|
||||||
|
## 6. Open questions for the reviewer
|
||||||
|
|
||||||
|
1. **CHECK constraint name verification** — 0014 drops constraints named `triggers_kind_check` and `outbox_source_kind_check` (Postgres's default for inline column-CHECKs). Please verify by running migrations from scratch + a fresh `\d+ triggers` / `\d+ outbox` against a stage DB before merge. The CHANGELOG includes a downgrade caveat but the upgrade path itself depends on this name match.
|
||||||
|
2. **`docs_repo` Postgres-integration tests** — I wrote SQL-shape tests against the QueryBuilder output (pure, no DB) but did **not** add `#[ignore]`-gated Postgres tests for the CRUD path. v1.1.1 also did not add them for KV's Postgres impl; following the precedent. If the reviewer wants live-DB tests for docs as a project standard, they can land in a follow-up — happy to do them in this branch if preferred.
|
||||||
|
3. **Parser promotion to `picloud-shared`** — Decision B says promote in v1.2 when `dead_letters::list` reuses it. If the reviewer wants the rename now (`picloud_shared::query::{Filter, FieldPath, ComparisonOp}`) to avoid the future rename, that's a quick mechanical move.
|
||||||
|
4. **Doc envelope future-proofing** — Decision D ships the explicit envelope. If a soft-delete `deleted_at` field gets added in v1.2, it should land inside the envelope (not inside `data`). The trait + repo would need a new optional column; the envelope shape stays flexible for it.
|
||||||
|
5. **Whether `find` should support `null`-LHS searches** — `$eq: null` correctly returns docs where the field is JSON-null OR missing (both produce SQL NULL via `jsonb_extract_path_text`). A user may expect `$eq: null` to mean *only* JSON-null (not missing). The current behavior matches the simplest mental model but I want this confirmed.
|
||||||
|
|
||||||
|
## 7. Deferred items beyond what the brief calls out
|
||||||
|
|
||||||
|
- **Postgres-integration tests for `docs_repo`** — see Open Question 2.
|
||||||
|
- **Dashboard surface for docs** — no UI in v1.1.2 (the brief notes this is fine; KV doesn't have completions in `rhai-mode.ts` either). Listed as a future UX-polish task.
|
||||||
|
- **Stable cursor encoding for `find`** — the v1.1.2 `find` doesn't paginate (returns all matches up to `$limit`). The v1.2 expansion (advanced query) should add cursor pagination to `find` to match `list`'s shape.
|
||||||
|
- **Dispatcher unit test for docs routing** — I considered extending the v1.1.1 dispatcher unit-test fixture (per the plan's test list) but the dispatcher's match-arm change is a single-line `Kv | DeadLetter | Docs` extension that's already covered by the existing `Kv` and `DeadLetter` arm tests. Adding a `Docs` clone wouldn't catch anything new; flagged here so the reviewer can decide.
|
||||||
|
|
||||||
|
## 8. How to verify locally
|
||||||
|
|
||||||
### Static checks (all green on this branch)
|
|
||||||
```sh
|
```sh
|
||||||
|
# 1. Lint + format + build + tests
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo clippy --all-targets --all-features -- -D warnings
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
cd dashboard && npm run check && npm run build
|
|
||||||
|
# 2. Fresh-DB migration test (assumes docker compose is set up)
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up -d postgres
|
||||||
|
cargo run -p picloud # observe 0001..0014 apply cleanly
|
||||||
|
|
||||||
|
# 3. Schema-on-top-of-v1.1.1 test
|
||||||
|
git checkout main
|
||||||
|
cargo run -p picloud # runs migrations through 0012
|
||||||
|
git checkout feat/v1.1.2-documents
|
||||||
|
cargo run -p picloud # observe 0013 + 0014 apply incrementally
|
||||||
|
|
||||||
|
# 4. End-to-end smoke (from the brief's "Done" checklist)
|
||||||
|
# a. Create an app + script via existing admin endpoints
|
||||||
|
# b. Bind the script to a route
|
||||||
|
# c. From a Rhai script via the route, exercise:
|
||||||
|
# let users = docs::collection("users");
|
||||||
|
# let id = users.create(#{ name: "Alice", tier: "gold", age: 30 });
|
||||||
|
# let doc = users.get(id);
|
||||||
|
# assert(doc.data.name == "Alice");
|
||||||
|
# let gold = users.find(#{ tier: "gold" });
|
||||||
|
# assert(gold.len() == 1);
|
||||||
|
# users.update(id, #{ name: "Alice", tier: "platinum", age: 30 });
|
||||||
|
# d. POST /api/v1/admin/apps/{id}/triggers/docs pointing at a
|
||||||
|
# logging handler script
|
||||||
|
# e. Update or delete the doc; verify the handler fires with
|
||||||
|
# ctx.event.docs.prev_data showing the prior state
|
||||||
|
|
||||||
|
# 5. Negative smoke
|
||||||
|
# users.find(#{ "$or": [...] }) → throws with v1.2 message
|
||||||
|
# users.find(#{ "a.b.c.d.e.f": "x" }) → depth-limit error
|
||||||
|
# docs::collection("") → empty-collection throw
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migration integrity
|
**Iteration-2 attestation** — run against this branch's HEAD (`bf26a25 chore: cargo fmt`) immediately before writing this section:
|
||||||
```sh
|
|
||||||
docker compose down -v && docker compose up -d postgres
|
|
||||||
cargo run -p picloud # applies 0001..0012 from empty
|
|
||||||
```
|
|
||||||
Then start from `main` (v1.1.0 schema state) and switch to this
|
|
||||||
branch; restart `picloud` to apply 0007..0012 on top.
|
|
||||||
|
|
||||||
### Manual end-to-end smoke (reviewer should run)
|
| Gate | Result |
|
||||||
```sh
|
|---|---|
|
||||||
docker compose up -d
|
| `cargo fmt --all -- --check` | exit 0 (no diff) |
|
||||||
# 1. Bootstrap an owner user via the existing flow + create app A.
|
| `cargo clippy --all-targets --all-features -- -D warnings` | exit 0 (no warnings) |
|
||||||
# 2. Create a script in A whose body is: throw "boom"
|
| `cargo test --workspace` | 320 passed, 0 failed, 132 ignored (Postgres-integration tests gated as expected) |
|
||||||
# 3. POST /api/v1/admin/apps/{A}/triggers/kv with
|
|
||||||
# {"script_id": "<broken>", "collection_glob": "*", "ops": ["insert"]}
|
|
||||||
# 4. From another script (or a public HTTP route):
|
|
||||||
# kv::collection("widgets").set("k1", #{n:1})
|
|
||||||
# 5. Wait ~7 seconds (3 attempts × ~1/2/4s backoff with ±20% jitter).
|
|
||||||
# 6. Open the dashboard at /admin.
|
|
||||||
# 7. Apps list shows a red "1" badge next to app A.
|
|
||||||
# 8. Click into app A → "Dead letters" tab link → row visible.
|
|
||||||
# 9. Click row → full payload + error history.
|
|
||||||
# 10. Click "Replay" → row marks resolution='replayed', new outbox
|
|
||||||
# row written, dispatcher re-runs the handler (fails again,
|
|
||||||
# produces a NEW DL row).
|
|
||||||
# 11. Click "Mark resolved" on the original DL → resolution='ignored'.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Async route smoke
|
The 77 new tests for v1.1.2 (26 docs_filter + 10 docs_repo SQL-shape + 23 docs_service + 3 triggers_api docs + 15 bridge integration) are all included in the 320 pass total. The original v1 HANDBACK §8 claimed these were green; the audit found a fmt diff that contradicted that claim. The chore commit `bf26a25` fixed the diff, and the table above is what `cargo` actually printed when I re-ran the gates after the fix. The HANDBACK update commit carries no code changes — it only replaces this section's text.
|
||||||
```sh
|
|
||||||
# Create a route via POST /api/v1/admin/scripts/{id}/routes with
|
|
||||||
# {"host_kind":"any","path_kind":"exact","path":"/work","dispatch_mode":"async"}
|
|
||||||
curl -X POST -d '{"work":"thing"}' http://localhost:8080/work
|
|
||||||
# Expect: HTTP 202 + {"accepted_at":"...","execution_id":"..."}
|
|
||||||
# Then tail execution_logs — the script ran later (not synchronously).
|
|
||||||
```
|
|
||||||
|
|
||||||
### Trigger-depth limit smoke
|
## 9. Known limitations / rough edges
|
||||||
```sh
|
|
||||||
# Set a low depth limit + register a KV trigger whose script
|
|
||||||
# writes to KV again — creates a loop.
|
|
||||||
PICLOUD_MAX_TRIGGER_DEPTH=3 cargo run -p picloud
|
|
||||||
# kv.set(...) from a script → triggers same script → depth hits 4
|
|
||||||
# Observe: depth-exceeded logged + outbox rows dropped (no DL spam).
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Known limitations / rough edges
|
- **Text-lex comparison for `$gt`/`$gte`/`$lt`/`$lte`** — per the brief's contract (Decision E). Breaks across digit-count boundaries (`'10' < '9'` is TRUE under any text collation). Documented in CHANGELOG. Workaround: zero-pad numeric strings. v1.2 advanced query adds numeric-aware operators.
|
||||||
|
- **Concurrent `update()` `prev_data` race** — the CTE pattern (`WITH prev AS (SELECT) UPDATE`) mirrors KV's `set` and inherits the same last-writer-wins race under `READ COMMITTED`: two simultaneous updates can both emit the same `prev_data` if their reads race. KV accepts this; docs follows. If audit-grade `prev_data` semantics are needed later, the fix is `WITH old AS (SELECT … FOR UPDATE)`.
|
||||||
|
- **Rollback from v1.1.2 → v1.1.1** with queued `docs`-source outbox rows will cause the v1.1.1 dispatcher to fail `TriggerEvent::Docs` deserialization (`#[serde(tag = "source")]` rejects unknown variants). Drain or delete `outbox WHERE source_kind = 'docs'` before downgrading. Trunk-only deployments don't hit this.
|
||||||
|
- **`find` doesn't paginate** — v1.1.2 returns all matches in one array (subject to `$limit`). Pagination on filter queries is deferred to v1.2's advanced query expansion.
|
||||||
|
- **Filter `Map` ordering not guaranteed** — Rhai's `Map` doesn't preserve insertion order, so when a filter contains multiple top-level fields the resulting `WHERE` clause's condition order can vary between runs. Result set is identical (AND is commutative); only the SQL string differs. No correctness impact.
|
||||||
|
- **The `find` integration tests use a custom `InMemoryDocs` impl** that does its own minimal filter eval (because the executor-core crate can't depend on manager-core's parser). The fake replicates the unsupported-operator throw path so the v1.2-pointer test exercises the bridge's error-propagation pipeline end to end.
|
||||||
|
|
||||||
- **No DB-backed integration tests in this branch.** Unit tests
|
## Closing note
|
||||||
cover trait behaviour with in-memory backings; sqlx query
|
|
||||||
correctness is verified by the workspace compile + manual smoke.
|
|
||||||
- **Dispatcher concurrency is in-process serial-per-tick.** Up to
|
|
||||||
8 rows claimed per tick, processed one at a time. Could be
|
|
||||||
parallelised with per-row `tokio::spawn` — kept serial for MVP
|
|
||||||
predictability (the gate already bounds total concurrent
|
|
||||||
executions globally).
|
|
||||||
- **Metric emission is TODO** at the three spots noted in
|
|
||||||
Open Questions §5. The behaviour they would observe is captured
|
|
||||||
via `tracing::warn`/`error` in the meantime.
|
|
||||||
- **`PostgresDeadLetterService::replay` doesn't restore the
|
|
||||||
original `trigger_depth`.** Replays start at depth 0. If a DL
|
|
||||||
row was originally produced at depth 7 with `max_trigger_depth=8`
|
|
||||||
and the replayed handler fans out again, it gets the full depth
|
|
||||||
budget. Acceptable for an admin-initiated replay (deliberate
|
|
||||||
retry), but worth noting if the reviewer disagrees.
|
|
||||||
- **HTTP outbox rows skip `is_dead_letter_handler` and the trigger-
|
|
||||||
principal path** since they don't originate from a trigger. The
|
|
||||||
`ResolvedTrigger` synthesized for them carries a sentinel zero
|
|
||||||
`AdminUserId` that's never used (HTTP rows never retry under
|
|
||||||
sync, and async-HTTP rows don't need a principal resolution).
|
|
||||||
- **DataPlaneState's executor field is still generic** (`Arc<E>`
|
|
||||||
where `E: ExecutorClient`). The dispatcher uses `Arc<dyn
|
|
||||||
ExecutorClient>` directly. The picloud binary clones the same
|
|
||||||
`Arc<LocalExecutorClient>` into both — works because the
|
|
||||||
concrete type implements both the trait object and the generic
|
|
||||||
bound.
|
|
||||||
- **dispatcher always sets `principal: None` for HTTP rows.** As
|
|
||||||
noted in Open Question §3, HTTP outbox rows don't resolve a
|
|
||||||
principal. Sync HTTP doesn't need one (caller is anonymous);
|
|
||||||
async HTTP currently can't authenticate as the originating
|
|
||||||
caller. If that's not the intent, additive change.
|
|
||||||
- **Cluster-mode crash recovery for claimed rows.** A claimed row
|
|
||||||
stays claimed indefinitely if the dispatcher crashes mid-
|
|
||||||
execution. v1.1.1 has one dispatcher per process so this is
|
|
||||||
rare; cluster mode (v1.3+) needs a stale-claim sweeper.
|
|
||||||
|
|
||||||
---
|
Reviewer audits the branch; on approval, the next step is to write `REVIEW.md` mirroring v1.1.1's audit-report format. The branch is ready.
|
||||||
|
|
||||||
Branch ready for review. Reviewer reads this report + audits the
|
|
||||||
diff. Do not merge to main until the audit clears.
|
|
||||||
|
|||||||
201
REVIEW.md
201
REVIEW.md
@@ -1,151 +1,140 @@
|
|||||||
# v1.1.1 Audit & Review
|
# v1.1.2 Audit & Review
|
||||||
|
|
||||||
**Branch:** `feat/v1.1.1-storage-and-events`
|
**Branch:** `feat/v1.1.2-documents`
|
||||||
**Base:** `main` (v1.1.0)
|
**Base:** `main` (v1.1.1 head)
|
||||||
**Commits ahead:** 12
|
**Commits ahead:** 9 (7 substantive + 2 from iteration 2)
|
||||||
**Audited by:** reviewer (this report)
|
**Audited by:** reviewer (this report)
|
||||||
**Audited against:** `docs/v1.1.x-design-notes.md` §1–4 (Decided 2026-06-01) + the original v1.1.1 dispatch prompt
|
**Audited against:** the v1.1.2 dispatch prompt + the v1.1.1-shipped patterns the prompt mandated
|
||||||
|
**Iterations:** 2 (iteration 1 returned for a format fix; iteration 2 fixed it cleanly)
|
||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
**APPROVE — ready to merge to `main` as v1.1.1.**
|
**APPROVE — ready to merge to `main` as v1.1.2.**
|
||||||
|
|
||||||
The implementation is faithful to every load-bearing decision in the design notes. Static checks are green, the workspace test suite passes (243 tests pass, 132 properly-ignored DB-backed cases, 0 failures), the schema matches Layout E exactly, and the documented deviations are all defensible. There is one ambient concern about a cross-crate dependency that should be reflected in `CLAUDE.md` after the merge, but it is not a merge blocker.
|
Substantive work was excellent on iteration 1; the only blocker was a single autoformatter diff at `docs_service.rs:456-457` that the iteration-1 HANDBACK incorrectly claimed was clean. Iteration 2 fixed the line (`bf26a25 chore: cargo fmt`), re-verified all three gates fresh on the new HEAD, replaced HANDBACK §8 with an honest attestation table, and explicitly recorded the discipline lesson in HANDBACK §1 for the v1.1.3 retro. Re-audit on the new HEAD is clean.
|
||||||
|
|
||||||
|
The 9-commit branch reads as a coherent release. Nothing else in the implementation needed changes between iterations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Static checks reproduced
|
## 1. Static checks reproduced (iteration 2 HEAD: `fedc63b`)
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo fmt --all -- --check ✅ clean
|
cargo fmt --all -- --check ✅ exit 0 (no diff)
|
||||||
cargo clippy --all-targets --all-features -- -D warnings ✅ no findings
|
cargo clippy --all-targets --all-features -- -D warnings ✅ exit 0 (no warnings)
|
||||||
cargo test --workspace ✅ 243 passed / 0 failed
|
cargo test --workspace ✅ 320 passed / 0 failed
|
||||||
(132 ignored — DB-backed integration tests,
|
+ 132 properly-ignored DB-backed
|
||||||
same convention as v1.1.0; documented in HANDBACK §4)
|
integration tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Test distribution per crate matches HANDBACK §4:
|
Per-crate test breakdown:
|
||||||
- manager-core: 63
|
- manager-core: 125 (62 new for v1.1.2: 26 docs_filter + 10 docs_repo sql-shape + 23 docs_service + 3 triggers_api docs)
|
||||||
- orchestrator-core: 56
|
- orchestrator-core: 56 (unchanged from v1.1.1)
|
||||||
- stdlib: 43
|
- stdlib: 43 (unchanged)
|
||||||
- sdk_contract: 30
|
- sdk_contract: 30 (unchanged)
|
||||||
- picloud: 21
|
- picloud: 21 (unchanged)
|
||||||
- executor-core (engine): 17
|
- executor-core engine: 17 (unchanged)
|
||||||
- sdk_kv: 7
|
- sdk_kv: 7 (unchanged)
|
||||||
- shared: 6
|
- sdk_docs: 15 (new in v1.1.2)
|
||||||
|
- shared: 6 (unchanged)
|
||||||
|
|
||||||
47 of these are new in v1.1.1; the rest are v1.1.0's existing suite still passing.
|
77 new tests — comfortably above the prompt's "30-50 new tests" target.
|
||||||
|
|
||||||
## 2. Design-notes conformance (spot-checks)
|
## 2. Design conformance (spot-checks)
|
||||||
|
|
||||||
| Decision | Where it lives | Verdict |
|
All items below were verified on iteration 1 and remain unchanged on iteration 2's HEAD (the format fix touched only whitespace).
|
||||||
|
|
||||||
|
| Decision / requirement | Where it lives | Verdict |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Layout E trigger storage (parent + per-kind detail) | [0008_triggers.sql:22-72](crates/manager-core/migrations/0008_triggers.sql#L22-L72) | ✅ matches exactly; parent has common columns + the four retry/dispatch knobs + `registered_by_principal`; per-kind detail tables for `kv` and `dead_letter` only |
|
| `docs::collection(name)` handle pattern + `::` namespace | [crates/executor-core/src/sdk/docs.rs](crates/executor-core/src/sdk/docs.rs) | ✅ Mirrors KV's shape exactly |
|
||||||
| `routes` stays separate from `triggers` parent | [0012_routes_dispatch_mode.sql](crates/manager-core/migrations/0012_routes_dispatch_mode.sql), [0009_outbox.sql:13-18](crates/manager-core/migrations/0009_outbox.sql#L13-L18) | ✅ HTTP rows use `source_kind = 'http'` and `trigger_id` references `routes.id`; non-HTTP references `triggers.id`; polymorphism in Rust per the design-notes deferral of the column-set refinement |
|
| Identity tuple `(app_id, collection, id)`, server-generated UUID | [0013_docs.sql:18-26](crates/manager-core/migrations/0013_docs.sql#L18-L26) | ✅ Primary key + server-generated id |
|
||||||
| Sync HTTP via outbox + NATS-style inbox | [inbox.rs:30-89](crates/orchestrator-core/src/inbox.rs#L30-L89), [dispatcher.rs:359-394](crates/manager-core/src/dispatcher.rs#L359-L394) | ✅ `oneshot::Sender<InboxResult>` keyed by inbox_id; `deliver()` returns `Delivered` or `Abandoned` exactly per the design-notes failure-mode table |
|
| Error convention (throw on failure, `()` for absent, `bool` for predicates) | [docs_service.rs](crates/manager-core/src/docs_service.rs), [sdk/docs.rs](crates/executor-core/src/sdk/docs.rs) | ✅ |
|
||||||
| `reply_to.is_some()` never retries | [dispatcher.rs:376-394](crates/manager-core/src/dispatcher.rs#L376-L394) | ✅ failure path checks `reply_to` first; delivers single outcome to inbox; deletes outbox row regardless of error |
|
| `app_id` from `cx.app_id`, never from script args | Service layer + SQL builder | ✅ Cross-app isolation test covers service; `every_query_starts_with_app_id_and_collection_predicate` pins it at the builder |
|
||||||
| Status code table (422/502/503/504/507/500) | [dispatcher.rs:555-564](crates/manager-core/src/dispatcher.rs#L555-L564), test [`failure_kind_status_codes_match_design_notes`](crates/manager-core/src/dispatcher.rs#L674) | ✅ exact mapping; covered by a dedicated test |
|
| Query DSL: 7 operators only (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`) | [docs_filter.rs ComparisonOp](crates/manager-core/src/docs_filter.rs) | ✅ Enum has exactly 7 variants |
|
||||||
| `dispatch_mode = async` returns `202 Accepted` + JSON body | [api.rs:325-332](crates/orchestrator-core/src/api.rs#L325-L332) | ✅ body shape is `{"accepted_at": rfc3339, "execution_id": uuid}` — matches design notes §2 verbatim |
|
| Unsupported operators throw with v1.2 pointer | docs_filter parser + 3 snapshot tests | ✅ Snapshot tests pin the error wording |
|
||||||
| Default retry: 3/exp/1000ms/±20% jitter | [trigger_config.rs](crates/manager-core/src/trigger_config.rs), tests [`exponential_backoff_doubles_per_attempt`](crates/manager-core/src/dispatcher.rs#L621), [`jitter_within_pct_of_base`](crates/manager-core/src/dispatcher.rs#L647) | ✅ env-overridable; jitter test exercises the ±20% bound across 100 samples |
|
| Dot-path field paths to depth 5 | [docs_filter.rs FieldPath::parse](crates/manager-core/src/docs_filter.rs) | ✅ Depth-limit + segment-validation tests |
|
||||||
| `abandoned_executions` written on dropped receiver | [dispatcher.rs:480-509](crates/manager-core/src/dispatcher.rs#L480-L509) | ✅ written only when `InboxDeliveryOutcome::Abandoned` returns; ordinary timeout-with-receiver-still-alive does not write a row |
|
| `$sort` single-field, `$limit` clamped | docs_filter parser | ✅ Multi-field-sort snapshot test; limit-clamp + negative-rejection tests |
|
||||||
| Dead-letter recursion stop (flag on execution) | [dispatcher.rs:396-425](crates/manager-core/src/dispatcher.rs#L396-L425), [trigger_repo.rs `TriggerKind::DeadLetter` → `is_dead_letter_handler`](crates/manager-core/src/dispatcher.rs#L228-L229) | ✅ flag set when dispatcher resolves a `kind = 'dead_letter'` trigger; on failure, original DL annotated with `resolution = 'handler_failed'`, row deleted, never retried, never DL'd |
|
| **SQL builder: every user input parameter-bound; no string interpolation** | [docs_repo.rs:319-420](crates/manager-core/src/docs_repo.rs#L319-L420) | ✅ Audited line-by-line; every value, every path segment, every `$in` array bound via `qb.push_bind(...)`. Only literal SQL is hardcoded keywords + operator tokens. `no_user_string_literal_in_sql` + `no_user_path_literal_in_sql` adversarial tests cover the safety net. |
|
||||||
| Sync HTTP failures do NOT dead-letter | [dispatcher.rs:378-394](crates/manager-core/src/dispatcher.rs#L378-L394) | ✅ early return before the DL-write block |
|
| `WHERE app_id = $1 AND collection = $2` always first | `every_query_starts_with_app_id_and_collection_predicate` test pins this across 8 filter shapes | ✅ |
|
||||||
| `dead_letters::list` NOT shipped (deferred to v1.2) | [executor-core/src/sdk/dead_letters.rs:13](crates/executor-core/src/sdk/dead_letters.rs#L13) | ✅ explicit doc-comment citing design notes §4; only `replay` + `resolve` registered |
|
| `$ne` uses `IS DISTINCT FROM`; `$eq null` → `IS NULL`; `$ne null` → `IS NOT NULL` | docs_repo.rs `ComparisonOp::Ne` + tests | ✅ Avoids NULL-handling traps |
|
||||||
| Trigger execution runs as registrant's principal | [dispatcher.rs:249-253](crates/manager-core/src/dispatcher.rs#L249-L253) + [`registered_by_principal` column](crates/manager-core/migrations/0008_triggers.sql#L39) | ✅ principal resolved from the trigger row at dispatch time |
|
| `docs:*` triggers via Layout E extension | [0014_docs_triggers.sql](crates/manager-core/migrations/0014_docs_triggers.sql) + trigger_repo.rs | ✅ Mirrors `kv_trigger_details`; CHECK constraints widened (not replaced) |
|
||||||
| 30-day DL retention, env-overridable | [gc.rs](crates/manager-core/src/gc.rs) | ✅ |
|
| Dispatcher routes `OutboxSourceKind::Docs` | dispatcher.rs match-arm extension | ✅ One-line `Kv \| DeadLetter \| Docs` change; reuses generic resolution path |
|
||||||
| 7-day abandoned-executions retention | [gc.rs](crates/manager-core/src/gc.rs) | ✅ |
|
| `ctx.event.docs.prev_data` change-data-capture | engine.rs `trigger_event_to_dynamic` + repo's update/delete return prior data | ✅ Works for update + delete; create has `prev_data = ()` |
|
||||||
| Trigger-depth limit (default 8); depth-exceeded does NOT dead-letter | [dispatcher.rs:122-137](crates/manager-core/src/dispatcher.rs#L122-L137) | ✅ design-notes §4 honored ("depth-exceeded means you built a loop") — row dropped + logged, no DL spam |
|
| `Capability::AppDocsRead/Write` mapped to `script:read`/`script:write` (no new scopes) | [authz.rs](crates/manager-core/src/authz.rs) | ✅ Seven-scope commitment honored |
|
||||||
| Dashboard surface: badge + list view + Replay + Mark resolved | [dashboard/src/routes/apps/+page.svelte](dashboard/src/routes/apps/+page.svelte), [dashboard/src/routes/apps/\[slug\]/dead-letters/+page.svelte](dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte) | ✅ all required columns + actions + expandable row detail; `npm run check` reports 0 errors |
|
| Per-mutation `ServiceEvent` emission via injected emitter | [outbox_event_emitter.rs emit_docs](crates/manager-core/src/outbox_event_emitter.rs) | ✅ Best-effort emit after success; mirrors KV |
|
||||||
| Status: workspace 1.1.0 → 1.1.1, SDK 1.1 → 1.2, dashboard 0.6.0 → 0.7.0, CHANGELOG.md created | last commit `66b661f` | ✅ |
|
|
||||||
| `ctx.event` shape (KV: source/op/collection/key/value; DL: original/attempts/last_error/ids/timestamps) | [shared/src/trigger_event.rs](crates/shared/src/trigger_event.rs), [executor-core engine tests](crates/executor-core/src/engine.rs) | ✅ matches design notes §4 shape exactly; tests verify both variants + the "absent for direct invocations" rule |
|
|
||||||
|
|
||||||
I sampled the design-notes diff (`git diff main..HEAD -- docs/v1.1.x-design-notes.md`) — every "Decided 2026-06-01" entry the agent absorbed into commit `1efb350` matches the decisions made in conversation. No drift.
|
## 3. Substantive strengths
|
||||||
|
|
||||||
## 3. Deviations from the prompt (all reviewed, all acceptable)
|
**SQL builder audit holds end-to-end.** [docs_repo.rs:319-420](crates/manager-core/src/docs_repo.rs#L319-L420) was traced line-by-line. Every user-controlled byte (path segments, scalar values, `$in` array contents, limit integer) is bound via `qb.push_bind(...)`. Only literal SQL the builder pushes is hardcoded keywords + operator tokens + structural punctuation. The cross-app isolation prefix is fixed at the top of every `build_find_query` call. The two adversarial-input tests (`no_user_string_literal_in_sql`, `no_user_path_literal_in_sql`) are exactly the safety net I'd want.
|
||||||
|
|
||||||
The HANDBACK's §3 lists nine deviations / mid-implementation decisions. My take on each:
|
**`prev_data` CTE pattern is correct.** Returns `Some(prev_data)` from a `WITH old AS (SELECT) UPDATE ... RETURNING (SELECT data FROM old)` shape. The HANDBACK §9 "Concurrent update prev_data race" caveat is honest: under `READ COMMITTED`, two simultaneous updates can both report the same `prev_data`. Same tradeoff as KV. For audit-grade triggers (v1.2+) the escalation to `SELECT ... FOR UPDATE` is the right fix.
|
||||||
|
|
||||||
1. **Outbox column set chosen** (`script_id`, `trigger_id` polymorphic, `claimed_by TEXT`, `trigger_depth`, `root_execution_id` denormalized; no `is_dead_letter_handler` column). The design notes explicitly deferred this set to implementation. The chosen shape is sensible: dispatcher can build `ExecRequest` without re-joining; the `is_dead_letter_handler` derivation from `triggers.kind` at dispatch time is cleaner than storing redundant state. ✅
|
**Layout E extension is mechanically clean.** Adding `docs` as a trigger kind required exactly: one new `<kind>_trigger_details` table, two one-line CHECK widenings (`triggers.kind` + `outbox.source_kind`), one new `TriggerEvent::Docs` variant, one match-arm extension in the dispatcher. Future kinds (cron v1.1.4, pubsub v1.1.5) should follow this template — v1.1.2's implementation is the proof that Layout E pays its design rent.
|
||||||
|
|
||||||
2. **KV pagination is cursor-style** (base64-encoded last-key, 100 default / 1000 max). The prompt left this open; cursor-style is the right default for KV-shaped data. ✅
|
**Operator-set is correct precedent.** The 7 operators are the right Pareto frontier — common cases that don't need parser infrastructure, while deferred operators (`$or`, `$and`, `$not`, `$regex`, `$exists`, etc.) all genuinely need infrastructure that v1.2 builds. The implicit-equality top-level + Mongo-style operator-object shape is consistent with what the TypeScript audience (v1.1.6 `@picloud/client`) will already know.
|
||||||
|
|
||||||
3. **KV TTL deferred**. Blueprint §8.1 reserved `expires_at` but v1.1.1 SDK doesn't surface TTL. Omitting the column from migration 0007 keeps the schema minimal; adding it later is a non-breaking forward migration. ✅ (CHANGELOG records the deferral.)
|
**Snapshot tests on error wording.** Three error messages pinned by snapshot tests (`$regex` rejection, multi-field-sort rejection, depth-limit rejection). Accidentally rephrasing during a future refactor will fail the build — right discipline because those strings are part of the user-facing contract.
|
||||||
|
|
||||||
4. **Authz scope mapping** (4 new capabilities mapped to existing 7 scopes — `AppKvRead → script:read`, `AppKvWrite → script:write`, `AppManageTriggers → app:admin`, `AppDeadLetterManage → app:admin`). The "seven-scope commitment" is a project convention in `crates/shared/src/auth.rs:103` the prompt didn't mention; honoring it is correct. The specific mapping is defensible: a token with `script:read` on an app already implies "can see the data behind those scripts," and admin-level scope for trigger/DL management is standard for control-plane operations. ✅
|
## 4. Schema decisions audited
|
||||||
|
|
||||||
5. **Script-as-gate authz** (`if cx.principal.is_some()` then check; else skip — public HTTP runs anonymously without an authz failure). This matches the SDK-shape doc's note that "the data plane is unauthenticated by default — public HTTP scripts run with `None`." Cross-app isolation is preserved regardless (every query keyed by `cx.app_id`). DL replay/resolve correctly bypasses this and hard-requires a principal. ✅
|
| HANDBACK §4 decision | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| GIN with `jsonb_path_ops` opclass | ✅ Smaller index, accelerates `@>` containment; range operators fall back to scan within small `(app_id, collection)` partition |
|
||||||
|
| Two migrations (0013_docs.sql + 0014_docs_triggers.sql) | ✅ Each revertable independently |
|
||||||
|
| Auto-named CHECK constraints | ✅ Postgres's `<table>_<column>_check` convention is stable 9.6+; works as designed |
|
||||||
|
| `docs_trigger_details.ops` without `DEFAULT '{}'` | ✅ Mirrors KV |
|
||||||
|
| No `dispatch_mode` on `docs_trigger_details` | ✅ Parent column suffices |
|
||||||
|
|
||||||
6. **Trait split `OutboxRepo` vs `OutboxWriter`**. Orchestrator-core can't depend on manager-core; the small `OutboxWriter` trait in shared (one method) lets the orchestrator enqueue HTTP rows without inverting the dependency arrow. ✅ Pattern mirrors the existing `members_concrete`/`AuthzRepo` split.
|
## 5. HANDBACK open questions — my answers
|
||||||
|
|
||||||
7. **`InboxResolver` in shared, `InboxRegistry` in orchestrator-core**. Same split rationale. Cluster mode (v1.3+) swaps the impl behind the unchanged trait. ✅
|
**Q1: CHECK-constraint name verification.** The auto-naming convention `<table>_<column>_check` is stable in Postgres 9.6+. Run a fresh-DB migration test before deploy as recommended, but not expected to fail. **Not a merge blocker.**
|
||||||
|
|
||||||
8. **manager-core now depends on executor-core**. ⚠️ **See §4 below — flagged, accepted, but should be reflected in `CLAUDE.md`.**
|
**Q2: Postgres-integration tests for `docs_repo`.** Defer following v1.1.1's precedent (KV doesn't have live-DB tests either). If the project later decides live-DB tests are a workspace standard, that's its own PR adding both KV and docs together.
|
||||||
|
|
||||||
9. **Sync HTTP via outbox is the default for user routes** (admin bypass `/api/v1/execute/{id}` keeps direct dispatch). Matches the design-notes decision; the bypass's direct path is acceptable for admin tooling speed. ✅
|
**Q3: Parser promotion to `picloud-shared` now or v1.2.** Defer to v1.2 as planned. Single consumer today; v1.2's "advanced query" expansion will mutate the parser's shape anyway; mechanical rename can land alongside `dead_letters::list`.
|
||||||
|
|
||||||
## 4. The one concern worth surfacing: manager-core → executor-core
|
**Q4: Doc envelope future-proofing for `deleted_at`.** Current shape leaves it naturally addable as a sibling field of `data`. Right shape.
|
||||||
|
|
||||||
`CLAUDE.md` working rules say:
|
**Q5: `$eq: null` semantics.** Current behavior (matches both JSON-null and missing path) is correct for v1.1.2. Users who need to distinguish them can express that combination in v1.2 with `$exists: true AND $eq: null`.
|
||||||
|
|
||||||
> Honor the three-service boundary. Don't reach across `*-core` crates. If
|
## 6. Smaller observations
|
||||||
> orchestrator-core needs something from manager-core, define a trait in
|
|
||||||
> shared and inject the impl.
|
|
||||||
|
|
||||||
The dispatcher in manager-core directly imports `ExecRequest`, `ExecResponse`, `ExecError`, and `InvocationType` from `executor-core`:
|
- `find` doesn't paginate in v1.1.2 — pagination on filter queries is deferred to v1.2 (HANDBACK §9). Acceptable.
|
||||||
|
- Filter `Map` ordering not stable (Rhai `Map` doesn't preserve insertion order). AND is commutative, so result sets are identical; only the emitted SQL string varies between runs.
|
||||||
|
- Text-lex comparison for range operators — `'10' < '9'` is TRUE under any text collation. Surfaced in CHANGELOG with the zero-pad workaround. v1.2's numeric-aware operators are the fix.
|
||||||
|
- Bridge integration tests use a custom `InMemoryDocs` fake that re-implements the unsupported-operator throw path (because executor-core can't depend on manager-core's parser). Acceptable; the real parser is exhaustively covered by manager-core unit tests.
|
||||||
|
|
||||||
```rust
|
## 7. Iteration 1 → iteration 2 deltas
|
||||||
// crates/manager-core/src/dispatcher.rs:27
|
|
||||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
|
||||||
```
|
|
||||||
|
|
||||||
The HANDBACK justifies this as "DTOs vs behavior — types are fine, behavior is the bright line." That's a defensible interpretation, but not what `CLAUDE.md` actually says.
|
Iteration 1 verdict was REQUEST-CHANGES on the sole basis of:
|
||||||
|
- `cargo fmt --check` failed at `docs_service.rs:456-457` (one-line collapse for the `$in` arm's `arr.iter().any(...)`)
|
||||||
|
- HANDBACK §8 explicitly claimed `cargo fmt --check` was green — false against the audited HEAD
|
||||||
|
|
||||||
**Two options the project can pick:**
|
Iteration 2 (2 new commits):
|
||||||
|
- `bf26a25 chore: cargo fmt` — the single-line collapse. Commit message honestly records the discipline gap ("the v1 HANDBACK §8 claimed `cargo fmt --check` was green; that claim was false against HEAD at audit time").
|
||||||
|
- `fedc63b docs(v1.1.2): handback §8 fresh post-fix attestation` — replaces §8's false claim with a verified-post-fix attestation table; adds an iteration note in §1 acknowledging the discipline gap for the v1.1.3 retro.
|
||||||
|
|
||||||
- **(a) Accept the dependency and update `CLAUDE.md`** to clarify that the three-service boundary is about *behavior*, not *types* — `ExecRequest`/`ExecResponse`/`ExecError` are transport DTOs and crossing the wire is normal. This is the lower-friction choice and matches how the agent's instincts ran.
|
Re-verification on iteration-2 HEAD:
|
||||||
- **(b) Refactor**: move `ExecRequest`/`ExecResponse`/`ExecError`/`InvocationType` to `shared`. About 200 lines of moves; would land cleanly as a follow-up PR.
|
- fmt: exit 0 (no diff) ✓
|
||||||
|
- clippy: exit 0 (no warnings) ✓
|
||||||
|
- tests: 320 passed, 0 failed, 132 ignored ✓
|
||||||
|
|
||||||
**My recommendation: (a)**. The dispatcher genuinely needs to construct and interpret these types, and they're the natural "what the executor produces" surface — burying them in shared makes the executor's public API less self-contained. But the rule as currently written disagrees; we should pick one explicitly.
|
All matches what the iteration-2 HANDBACK §8 claims. No drift between claim and reality this time.
|
||||||
|
|
||||||
This is **not a merge blocker** for v1.1.1 — the implementation already exists and works. The CLAUDE.md update can land as a small commit on `main` after the merge.
|
## 8. Versioning audit
|
||||||
|
|
||||||
## 5. Smaller observations (no action required)
|
|
||||||
|
|
||||||
- **HTTP outbox rows synthesize a `ResolvedTrigger` with a sentinel zero `AdminUserId`** ([dispatcher.rs:342](crates/manager-core/src/dispatcher.rs#L342)). The HANDBACK flags this as a code smell; I agree, but the cleaner shape (`enum DispatchTarget { Trigger(ResolvedTrigger), Http(HttpRoute) }`) is a refactor that doesn't belong in v1.1.1. Worth doing in v1.1.2 alongside the docs work since the dispatcher will gain another trigger kind.
|
|
||||||
- **Triggers parent `dispatch_mode` defaults to `'async'`** ([0008_triggers.sql:30](crates/manager-core/migrations/0008_triggers.sql#L30)) with `sync` allowed by the CHECK constraint but unsupported in v1.1.1 (sync trigger would mean firing inline with the originating mutation, which we don't do). The migration comment captures this; worth a future commit to either remove `'sync'` from the CHECK or use it for an `inline_pre_mutate` semantics if it ever makes sense. Not v1.1.1's problem.
|
|
||||||
- **Metric counters are TODO** at three call sites (`picloud_trigger_depth_exceeded`, `picloud_dead_letter_handler_failures`, `picloud_abandoned_executions_total`). The events are logged via `tracing::warn`/`error` in the meantime. Per the prompt and roadmap, metrics surface is v1.1.7+. ✅
|
|
||||||
- **Dispatcher tick cadence is 100ms with `CLAIM_BATCH = 8`**, serial per tick. The ExecutionGate bounds total concurrent executions globally, so parallelism within a tick is purely an optimization. Reasonable MVP choice; can parallelize later without changing semantics.
|
|
||||||
- **Open Q1 in HANDBACK (claimed-rows-stuck-on-crash)** is a real cluster-mode concern, correctly out-of-scope for v1.1.1 (single dispatcher per process). Cluster mode adds a stale-claim sweeper — track for v1.3+.
|
|
||||||
- **Open Q3 in HANDBACK (HTTP-triggered scripts run with `principal: None`)** is correct as-is. The "trigger executions inherit the registrant's principal" decision applies to triggers; HTTP routes have no registrant in that sense. Public HTTP is anonymous by design.
|
|
||||||
|
|
||||||
## 6. Versioning audit
|
|
||||||
|
|
||||||
| File | Before | After | Status |
|
| File | Before | After | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Workspace `Cargo.toml` (workspace.package.version) | 1.1.0 | 1.1.1 | ✅ |
|
| Workspace `Cargo.toml` | 1.1.1 | 1.1.2 | ✅ |
|
||||||
| SDK schema version (`shared/src/version.rs`) | 1.1 | 1.2 | ✅ correctly bumped — the SDK surface added `KvService` + `DeadLetterService` + `TriggerEvent` |
|
| SDK schema (`shared/src/version.rs`) | 1.2 | 1.3 | ✅ Services bundle gains `docs: Arc<dyn DocsService>` |
|
||||||
| Dashboard `package.json` | 0.6.0 | 0.7.0 | ✅ |
|
| Dashboard `package.json` | 0.7.0 | 0.8.0 | ✅ (alignment with workspace) |
|
||||||
| Migrations | 0001..0006 | 0007..0012 added | ✅ sequential, no skips |
|
| Migrations | 0001..0012 | 0013, 0014 added | ✅ Sequential, no skips |
|
||||||
| CHANGELOG.md | not present | created at repo root | ✅ first entry covers v1.1.1 |
|
| CHANGELOG.md | v1.1.1 entry | v1.1.2 entry appended | ✅ |
|
||||||
|
|
||||||
## 7. Manual smoke recommendation
|
## 9. Recommended next steps
|
||||||
|
|
||||||
The reviewer (you) does **not** need to run the manual end-to-end smoke before merging — the automated tests + the static review above cover the contracts. The smoke flow in HANDBACK §7 is worth running **after merge** as a release-validation step before tagging `v1.1.1` (if the project tags releases). Specifically:
|
1. **Merge** `feat/v1.1.2-documents` into `main` (fast-forward; branch is linear ahead).
|
||||||
|
2. **Pause** before dispatching v1.1.3 (Modules). The v1.1.2 work establishes the query-DSL precedent that v1.2 will lean on (`dead_letters::list`, "advanced docs query"); worth a brief mental check before the next dispatch that nothing in v1.1.2's shape has prompted a roadmap revision.
|
||||||
|
3. **Carry the discipline lesson forward.** The v1.1.3 prompt should include a "verify all three gates on the exact commit you're handing back, then write HANDBACK §8 from that fresh output" reminder. Cost is one sentence; benefit is removing the only audit finding from v1.1.2.
|
||||||
|
|
||||||
1. `docker compose up -d` (fresh DB)
|
Branch ready for merge. **Verdict: APPROVE.**
|
||||||
2. `cargo run -p picloud`
|
|
||||||
3. Create app + script-that-throws + KV trigger
|
|
||||||
4. Trigger a KV write → wait ~7s → confirm DL row appears
|
|
||||||
5. Dashboard: red badge on apps list, list view shows the row, Replay creates a new outbox row + dispatcher re-runs, Mark resolved sets `resolution = 'ignored'`
|
|
||||||
6. Async route test: `POST /work` with `dispatch_mode=async` route → expect 202 + JSON body
|
|
||||||
|
|
||||||
If any of those misbehave post-merge, revert is straightforward (12 commits, ahead of main, no dependencies have pulled changes yet).
|
|
||||||
|
|
||||||
## 8. Recommended next steps (post-merge)
|
|
||||||
|
|
||||||
1. **Merge** `feat/v1.1.1-storage-and-events` into `main` (fast-forward; branch is linear ahead).
|
|
||||||
2. **Tag** `v1.1.1` if release tagging is the project convention (git log shows v1.1.0 had a release commit but I didn't see a tag — confirm with the project owner).
|
|
||||||
3. **Small CLAUDE.md update** clarifying the three-service boundary's scope (types crossing is fine; behavior crossing is what's prohibited). One-paragraph change.
|
|
||||||
4. **Pause** before dispatching the v1.1.2 (Documents) agent — the v1.1.1 work shipped substantial infrastructure that v1.1.2 will lean on, and there may be small lessons from the v1.1.1 implementation to fold into the v1.1.2 prompt (e.g., reaffirming the "manager-core depends on executor-core for DTOs" pattern explicitly so the docs agent doesn't second-guess it).
|
|
||||||
|
|
||||||
Branch is ready for merge. Verdict: **APPROVE**.
|
|
||||||
|
|||||||
@@ -278,6 +278,27 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
|||||||
);
|
);
|
||||||
m.insert("kv".into(), kv_map.into());
|
m.insert("kv".into(), kv_map.into());
|
||||||
}
|
}
|
||||||
|
TriggerEvent::Docs {
|
||||||
|
op,
|
||||||
|
collection,
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
prev_data,
|
||||||
|
} => {
|
||||||
|
m.insert("op".into(), op.as_str().into());
|
||||||
|
let mut docs_map = Map::new();
|
||||||
|
docs_map.insert("collection".into(), collection.clone().into());
|
||||||
|
docs_map.insert("id".into(), id.clone().into());
|
||||||
|
docs_map.insert(
|
||||||
|
"data".into(),
|
||||||
|
data.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||||
|
);
|
||||||
|
docs_map.insert(
|
||||||
|
"prev_data".into(),
|
||||||
|
prev_data.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||||
|
);
|
||||||
|
m.insert("docs".into(), docs_map.into());
|
||||||
|
}
|
||||||
TriggerEvent::DeadLetter {
|
TriggerEvent::DeadLetter {
|
||||||
dead_letter_id,
|
dead_letter_id,
|
||||||
original,
|
original,
|
||||||
|
|||||||
255
crates/executor-core/src/sdk/docs.rs
Normal file
255
crates/executor-core/src/sdk/docs.rs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
//! `docs::` Rhai bridge — collection-scoped handle pattern, v1.1.2.
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! let users = docs::collection("users");
|
||||||
|
//! let id = users.create(#{ name: "Alice", tier: "gold" });
|
||||||
|
//! let doc = users.get(id); // envelope or () if missing
|
||||||
|
//! let golds = users.find(#{ tier: "gold" });
|
||||||
|
//! let one = users.find_one(#{ tier: "gold" });
|
||||||
|
//! users.update(id, #{ name: "Alice", tier: "platinum" });
|
||||||
|
//! let removed = users.delete(id); // bool was-present
|
||||||
|
//! let page = users.list(#{ cursor: (), limit: 100 });
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Mirrors `kv.rs`: `DocsHandle` captures the collection + service +
|
||||||
|
//! per-call cx; methods bind via `engine.register_fn` so scripts call
|
||||||
|
//! them with dot-notation. **The service derives `app_id` from
|
||||||
|
//! `cx.app_id` — never from any closure argument.** Cross-app
|
||||||
|
//! isolation boundary; same as KV.
|
||||||
|
//!
|
||||||
|
//! Doc shape returned by `get`/`find`/`find_one`/`list`: an envelope
|
||||||
|
//! `#{ id, data: #{...}, created_at, updated_at }`. Decision D in the
|
||||||
|
//! v1.1.2 plan — explicit metadata vs user-data separation.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{DocId, DocRow, DocsError, DocsService, SdkCallCx, Services};
|
||||||
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
|
|
||||||
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||||
|
/// plus an owned string).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DocsHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn DocsService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let docs_service = services.docs.clone();
|
||||||
|
|
||||||
|
let mut module = Module::new();
|
||||||
|
{
|
||||||
|
let docs_service = docs_service.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"collection",
|
||||||
|
move |name: &str| -> Result<DocsHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("docs::collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(DocsHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: docs_service.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
engine.register_static_module("docs", module.into());
|
||||||
|
|
||||||
|
engine.register_type_with_name::<DocsHandle>("DocsHandle");
|
||||||
|
|
||||||
|
register_create(engine);
|
||||||
|
register_get(engine);
|
||||||
|
register_find(engine);
|
||||||
|
register_find_one(engine);
|
||||||
|
register_update(engine);
|
||||||
|
register_delete(engine);
|
||||||
|
register_list(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_create(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"create",
|
||||||
|
|handle: &mut DocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(data));
|
||||||
|
let id = block_on(async move { h.service.create(&h.cx, &h.collection, json).await })?;
|
||||||
|
Ok(id.to_string())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_get(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"get",
|
||||||
|
|handle: &mut DocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
let row =
|
||||||
|
block_on(async move { h.service.get(&h.cx, &h.collection, parsed_id).await })?;
|
||||||
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_find(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"find",
|
||||||
|
|handle: &mut DocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||||
|
let rows = block_on(async move { h.service.find(&h.cx, &h.collection, json).await })?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||||
|
.collect::<Vec<Dynamic>>())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_find_one(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"find_one",
|
||||||
|
|handle: &mut DocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||||
|
let row =
|
||||||
|
block_on(async move { h.service.find_one(&h.cx, &h.collection, json).await })?;
|
||||||
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_update(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"update",
|
||||||
|
|handle: &mut DocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(data));
|
||||||
|
block_on(async move {
|
||||||
|
h.service
|
||||||
|
.update(&h.cx, &h.collection, parsed_id, json)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_delete(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"delete",
|
||||||
|
|handle: &mut DocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
block_on(async move { h.service.delete(&h.cx, &h.collection, parsed_id).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_list(engine: &mut RhaiEngine) {
|
||||||
|
// Zero-arg form: full page from the start.
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut DocsHandle| -> Result<Map, Box<EvalAltResult>> { list_call(handle, None, 0) },
|
||||||
|
);
|
||||||
|
// One-arg form: pass `#{ cursor, limit }` map. Either field is
|
||||||
|
// optional; missing/unit → defaults.
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut DocsHandle, args: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let cursor = match args.get("cursor") {
|
||||||
|
Some(d) if !d.is_unit() => {
|
||||||
|
Some(d.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"docs::list: 'cursor' must be a string or ()".into()
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let limit = match args.get("limit") {
|
||||||
|
Some(d) if !d.is_unit() => {
|
||||||
|
let n = d.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"docs::list: 'limit' must be an integer".into()
|
||||||
|
})?;
|
||||||
|
u32::try_from(n.max(0)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
list_call(handle, cursor, limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_call(
|
||||||
|
handle: &DocsHandle,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let page = block_on(async move {
|
||||||
|
h.service
|
||||||
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
let docs: Array = page
|
||||||
|
.docs
|
||||||
|
.iter()
|
||||||
|
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||||
|
.collect();
|
||||||
|
m.insert("docs".into(), docs.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `{ id, data, created_at, updated_at }` envelope per
|
||||||
|
/// Decision D. Scripts read user fields via `doc.data.<field>`; `id`
|
||||||
|
/// and timestamps are direct children of the envelope.
|
||||||
|
fn doc_to_map(doc: &DocRow) -> Map {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("id".into(), doc.id.to_string().into());
|
||||||
|
m.insert("data".into(), json_to_dynamic(doc.data.clone()));
|
||||||
|
m.insert("created_at".into(), doc.created_at.to_rfc3339().into());
|
||||||
|
m.insert("updated_at".into(), doc.updated_at.to_rfc3339().into());
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_doc_id(id: &str) -> Result<DocId, Box<EvalAltResult>> {
|
||||||
|
Uuid::parse_str(id).map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("docs: invalid id '{id}': {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `kv.rs::block_on` — Tokio runtime is reachable from inside
|
||||||
|
/// the `spawn_blocking` wrapper that owns Rhai execution. Errors
|
||||||
|
/// prefix with `"docs: "` so scripts see `docs: forbidden`,
|
||||||
|
/// `docs: document not found`, `docs: unsupported operator: …`, etc.
|
||||||
|
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = Result<T, DocsError>> + Send,
|
||||||
|
T: Send,
|
||||||
|
{
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("docs: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("docs: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
pub mod cx;
|
pub mod cx;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
|
pub mod docs;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod stdlib;
|
pub mod stdlib;
|
||||||
|
|
||||||
@@ -33,5 +34,6 @@ use rhai::Engine as RhaiEngine;
|
|||||||
/// single `<service>::register(...)` line per service.
|
/// single `<service>::register(...)` line per service.
|
||||||
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
kv::register(engine, services, cx.clone());
|
kv::register(engine, services, cx.clone());
|
||||||
|
docs::register(engine, services, cx.clone());
|
||||||
dead_letters::register(engine, services, cx);
|
dead_letters::register(engine, services, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
519
crates/executor-core/tests/sdk_docs.rs
Normal file
519
crates/executor-core/tests/sdk_docs.rs
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
//! `docs::` SDK bridge integration tests — runs a real Rhai engine
|
||||||
|
//! against an in-memory `DocsService` impl. Mirrors `tests/sdk_kv.rs`:
|
||||||
|
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
|
||||||
|
//! reachable runtime.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
|
use picloud_shared::{
|
||||||
|
AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
|
||||||
|
NoopEventEmitter, NoopKvService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryDocs {
|
||||||
|
data: Mutex<HashMap<(AppId, String, DocId), DocRow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DocsService for InMemoryDocs {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
data: Value,
|
||||||
|
) -> Result<DocId, DocsError> {
|
||||||
|
if !data.is_object() {
|
||||||
|
return Err(DocsError::InvalidData);
|
||||||
|
}
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let row = DocRow {
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
self.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert((cx.app_id, collection.to_string(), id), row);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<DocRow>, DocsError> {
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(cx.app_id, collection.to_string(), id))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
filter: Value,
|
||||||
|
) -> Result<Vec<DocRow>, DocsError> {
|
||||||
|
// Tiny eval: extract top-level equalities + $in arrays + $gt
|
||||||
|
// (text lex) so the bridge tests can run end-to-end against a
|
||||||
|
// fake. This fake mirrors the real service's reject-unsupported
|
||||||
|
// contract so the v1.2-pointer-error test goes through the
|
||||||
|
// bridge's error-propagation path.
|
||||||
|
let map = self.data.lock().await;
|
||||||
|
let obj = filter
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| DocsError::InvalidFilter("filter must be a map/object".into()))?;
|
||||||
|
reject_unsupported_operators(obj)?;
|
||||||
|
let mut out: Vec<DocRow> = map
|
||||||
|
.iter()
|
||||||
|
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.filter(|row| matches_simple(&row.data, obj))
|
||||||
|
.collect();
|
||||||
|
if let Some(limit) = obj.get("$limit").and_then(Value::as_u64) {
|
||||||
|
out.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_one(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
filter: Value,
|
||||||
|
) -> Result<Option<DocRow>, DocsError> {
|
||||||
|
Ok(self.find(cx, collection, filter).await?.into_iter().next())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
data: Value,
|
||||||
|
) -> Result<(), DocsError> {
|
||||||
|
if !data.is_object() {
|
||||||
|
return Err(DocsError::InvalidData);
|
||||||
|
}
|
||||||
|
let mut map = self.data.lock().await;
|
||||||
|
let key = (cx.app_id, collection.to_string(), id);
|
||||||
|
let Some(row) = map.get_mut(&key) else {
|
||||||
|
return Err(DocsError::NotFound);
|
||||||
|
};
|
||||||
|
row.data = data;
|
||||||
|
row.updated_at = Utc::now();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError> {
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&(cx.app_id, collection.to_string(), id))
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
_cursor: Option<&str>,
|
||||||
|
_limit: u32,
|
||||||
|
) -> Result<DocsListPage, DocsError> {
|
||||||
|
let mut docs: Vec<DocRow> = self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.collect();
|
||||||
|
docs.sort_by_key(|d| d.id);
|
||||||
|
Ok(DocsListPage {
|
||||||
|
docs,
|
||||||
|
next_cursor: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan an operator object for any `$xxx` key not in the v1.1.2
|
||||||
|
/// allowlist and return the same shape of error the real parser
|
||||||
|
/// emits. Top-level `$limit` is the only allowed modifier the fake
|
||||||
|
/// engages with; the unsupported test passes `$regex`.
|
||||||
|
fn reject_unsupported_operators(obj: &serde_json::Map<String, Value>) -> Result<(), DocsError> {
|
||||||
|
const SUPPORTED_TOP_LEVEL: &[&str] = &["$limit", "$sort"];
|
||||||
|
const SUPPORTED_NESTED: &[&str] = &["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"];
|
||||||
|
for (key, value) in obj {
|
||||||
|
if let Some(stripped) = key.strip_prefix('$') {
|
||||||
|
if !SUPPORTED_TOP_LEVEL.contains(&key.as_str()) {
|
||||||
|
return Err(DocsError::UnsupportedOperator(format!(
|
||||||
|
"docs::find: top-level modifier '${stripped}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(inner) = value.as_object() {
|
||||||
|
for op_key in inner.keys() {
|
||||||
|
if op_key.starts_with('$') && !SUPPORTED_NESTED.contains(&op_key.as_str()) {
|
||||||
|
return Err(DocsError::UnsupportedOperator(format!(
|
||||||
|
"docs::find: operator '{op_key}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_simple(data: &Value, filter: &serde_json::Map<String, Value>) -> bool {
|
||||||
|
for (key, want) in filter {
|
||||||
|
if key.starts_with('$') {
|
||||||
|
// $limit handled in the find body.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let actual = data.get(key);
|
||||||
|
if let Some(obj) = want.as_object() {
|
||||||
|
// operator object — handle $in and $gt only (enough for
|
||||||
|
// the bridge tests to exercise the round-trip).
|
||||||
|
if let Some(arr) = obj.get("$in").and_then(Value::as_array) {
|
||||||
|
let Some(actual) = actual else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !arr.iter().any(|v| v == actual) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(gt) = obj.get("$gt") {
|
||||||
|
let Some(actual) = actual else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let a = actual.as_str().unwrap_or("");
|
||||||
|
let b = gt.as_str().unwrap_or("");
|
||||||
|
if a <= b {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if Some(want) != actual {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_engine() -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(InMemoryDocs::default()),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
);
|
||||||
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
script_name: "docs-test".into(),
|
||||||
|
invocation_type: InvocationType::Http,
|
||||||
|
path: "/docs-test".into(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Value::Null,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||||
|
let src = src.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic")
|
||||||
|
.expect("script execution should succeed")
|
||||||
|
.body
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_create_then_get_round_trip() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let users = docs::collection("users");
|
||||||
|
let id = users.create(#{ name: "Alice", tier: "gold" });
|
||||||
|
let doc = users.get(id);
|
||||||
|
#{ id_matches: doc.id == id, data_name: doc.data.name }
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
let obj = body.as_object().unwrap();
|
||||||
|
assert_eq!(obj["id_matches"], json!(true));
|
||||||
|
assert_eq!(obj["data_name"], json!("Alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_get_missing_returns_unit() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
let v = c.get("00000000-0000-0000-0000-000000000000");
|
||||||
|
v == ()
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_get_with_invalid_uuid_throws() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"docs::collection("users").get("not-a-uuid")"#;
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect_err("invalid uuid should throw");
|
||||||
|
assert!(format!("{err:?}").contains("invalid id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_find_equality_returns_matches() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
c.create(#{ tier: "gold" });
|
||||||
|
c.create(#{ tier: "silver" });
|
||||||
|
c.create(#{ tier: "gold" });
|
||||||
|
let golds = c.find(#{ tier: "gold" });
|
||||||
|
golds.len()
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, json!(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_find_with_in_operator() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
c.create(#{ tier: "gold" });
|
||||||
|
c.create(#{ tier: "silver" });
|
||||||
|
c.create(#{ tier: "platinum" });
|
||||||
|
let hits = c.find(#{ tier: #{ "$in": ["gold", "platinum"] } });
|
||||||
|
hits.len()
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, json!(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_find_with_gt_comparison() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("events");
|
||||||
|
c.create(#{ when: "2026-01-15" });
|
||||||
|
c.create(#{ when: "2026-03-15" });
|
||||||
|
c.create(#{ when: "2026-05-15" });
|
||||||
|
let recent = c.find(#{ when: #{ "$gt": "2026-02-01" } });
|
||||||
|
recent.len()
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, json!(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_find_one_returns_envelope_or_unit() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
c.create(#{ tier: "gold" });
|
||||||
|
let hit = c.find_one(#{ tier: "gold" });
|
||||||
|
let miss = c.find_one(#{ tier: "platinum" });
|
||||||
|
#{ hit_has_data: hit.data.tier == "gold", miss_is_unit: miss == () }
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
let obj = body.as_object().unwrap();
|
||||||
|
assert_eq!(obj["hit_has_data"], json!(true));
|
||||||
|
assert_eq!(obj["miss_is_unit"], json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_update_then_get_reflects_change() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
let id = c.create(#{ name: "Alice", tier: "gold" });
|
||||||
|
c.update(id, #{ name: "Alice", tier: "platinum" });
|
||||||
|
c.get(id).data.tier
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, json!("platinum"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_update_missing_throws() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
c.update("00000000-0000-0000-0000-000000000000", #{ x: 1 })
|
||||||
|
"#;
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect_err("update missing should throw");
|
||||||
|
assert!(format!("{err:?}").contains("not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_delete_returns_was_present() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
let nope = c.delete("00000000-0000-0000-0000-000000000000");
|
||||||
|
let id = c.create(#{ x: 1 });
|
||||||
|
let yep = c.delete(id);
|
||||||
|
#{ nope: nope, yep: yep }
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, json!({ "nope": false, "yep": true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_unsupported_operator_throws_with_v1_2_pointer() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
c.find(#{ name: #{ "$regex": "^A" } })
|
||||||
|
"#;
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect_err("unsupported operator should throw");
|
||||||
|
let msg = format!("{err:?}");
|
||||||
|
assert!(msg.contains("$regex"), "msg: {msg}");
|
||||||
|
assert!(msg.contains("v1.2"), "msg: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_empty_collection_name_throws() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"docs::collection("")"#;
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect_err("empty collection should throw");
|
||||||
|
assert!(format!("{err:?}").contains("docs::collection"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_list_returns_docs_array() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
c.create(#{ a: 1 });
|
||||||
|
c.create(#{ a: 2 });
|
||||||
|
let page = c.list();
|
||||||
|
page.docs.len()
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
assert_eq!(body, json!(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross-app isolation through the bridge — script with `app_id = A`
|
||||||
|
/// must NOT see documents written from `app_id = B` even when the
|
||||||
|
/// (collection, id) tuple is shared. The bridge captures `cx.app_id`
|
||||||
|
/// via `Arc<SdkCallCx>` and the service derives storage `app_id` from
|
||||||
|
/// it (never from a script arg).
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_bridge_preserves_cross_app_isolation() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app_a = AppId::new();
|
||||||
|
let app_b = AppId::new();
|
||||||
|
|
||||||
|
let writer = r#"
|
||||||
|
let c = docs::collection("shared");
|
||||||
|
let id = c.create(#{ from: "a" });
|
||||||
|
id
|
||||||
|
"#;
|
||||||
|
let id_a = run_script(engine.clone(), writer, baseline_request(app_a)).await;
|
||||||
|
let id_a_str = id_a.as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// App B looks up the same id under the same collection — should
|
||||||
|
// see nothing because the service keyed it by app_id = A.
|
||||||
|
let reader_src = format!(
|
||||||
|
r#"
|
||||||
|
let c = docs::collection("shared");
|
||||||
|
let v = c.get("{id_a_str}");
|
||||||
|
v == ()
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let body = run_script(engine, &reader_src, baseline_request(app_b)).await;
|
||||||
|
assert_eq!(body, json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn docs_envelope_has_id_data_created_at_updated_at() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let app = AppId::new();
|
||||||
|
let src = r#"
|
||||||
|
let c = docs::collection("users");
|
||||||
|
let id = c.create(#{ name: "Alice" });
|
||||||
|
let doc = c.get(id);
|
||||||
|
// Probe each envelope field is present + correctly typed.
|
||||||
|
#{
|
||||||
|
has_id: type_of(doc.id) == "string",
|
||||||
|
has_data: type_of(doc.data) == "map",
|
||||||
|
has_created_at: type_of(doc.created_at) == "string",
|
||||||
|
has_updated_at: type_of(doc.updated_at) == "string",
|
||||||
|
user_field: doc.data.name
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(app)).await;
|
||||||
|
let obj = body.as_object().unwrap();
|
||||||
|
assert_eq!(obj["has_id"], json!(true));
|
||||||
|
assert_eq!(obj["has_data"], json!(true));
|
||||||
|
assert_eq!(obj["has_created_at"], json!(true));
|
||||||
|
assert_eq!(obj["has_updated_at"], json!(true));
|
||||||
|
assert_eq!(obj["user_field"], json!("Alice"));
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopEventEmitter,
|
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
|
||||||
RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
NoopEventEmitter, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -101,6 +101,7 @@ impl KvService for InMemoryKv {
|
|||||||
fn make_engine() -> Arc<Engine> {
|
fn make_engine() -> Arc<Engine> {
|
||||||
let services = Services::new(
|
let services = Services::new(
|
||||||
Arc::new(InMemoryKv::default()),
|
Arc::new(InMemoryKv::default()),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
Arc::new(NoopDeadLetterService),
|
Arc::new(NoopDeadLetterService),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
);
|
);
|
||||||
|
|||||||
39
crates/manager-core/migrations/0013_docs.sql
Normal file
39
crates/manager-core/migrations/0013_docs.sql
Normal 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);
|
||||||
36
crates/manager-core/migrations/0014_docs_triggers.sql
Normal file
36
crates/manager-core/migrations/0014_docs_triggers.sql
Normal 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
|
||||||
|
);
|
||||||
@@ -64,6 +64,14 @@ pub enum Capability {
|
|||||||
/// Write entries to this app's KV store (v1.1.1). Granted to
|
/// Write entries to this app's KV store (v1.1.1). Granted to
|
||||||
/// `editor`+. Maps to `script:write` on API keys.
|
/// `editor`+. Maps to `script:write` on API keys.
|
||||||
AppKvWrite(AppId),
|
AppKvWrite(AppId),
|
||||||
|
/// Read documents from this app's docs store (v1.1.2). Same trust
|
||||||
|
/// shape as KV read — granted to `viewer`+, maps to `script:read`
|
||||||
|
/// on API keys. Honors the seven-scope commitment.
|
||||||
|
AppDocsRead(AppId),
|
||||||
|
/// Write documents to this app's docs store (v1.1.2). Same trust
|
||||||
|
/// shape as KV write — granted to `editor`+, maps to
|
||||||
|
/// `script:write` on API keys.
|
||||||
|
AppDocsWrite(AppId),
|
||||||
/// Create / list / delete triggers for this app (v1.1.1). Maps to
|
/// Create / list / delete triggers for this app (v1.1.1). Maps to
|
||||||
/// `app:admin` on API keys — triggers are app-configuration acts
|
/// `app:admin` on API keys — triggers are app-configuration acts
|
||||||
/// rather than data-plane access. Granted to `app_admin`+.
|
/// rather than data-plane access. Granted to `app_admin`+.
|
||||||
@@ -91,6 +99,8 @@ impl Capability {
|
|||||||
| Self::AppLogRead(id)
|
| Self::AppLogRead(id)
|
||||||
| Self::AppKvRead(id)
|
| Self::AppKvRead(id)
|
||||||
| Self::AppKvWrite(id)
|
| Self::AppKvWrite(id)
|
||||||
|
| Self::AppDocsRead(id)
|
||||||
|
| Self::AppDocsWrite(id)
|
||||||
| Self::AppManageTriggers(id)
|
| Self::AppManageTriggers(id)
|
||||||
| Self::AppDeadLetterManage(id) => Some(id),
|
| Self::AppDeadLetterManage(id) => Some(id),
|
||||||
}
|
}
|
||||||
@@ -107,8 +117,10 @@ impl Capability {
|
|||||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
Scope::InstanceAdmin
|
Scope::InstanceAdmin
|
||||||
}
|
}
|
||||||
Self::AppRead(_) | Self::AppKvRead(_) => Scope::ScriptRead,
|
Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_) | Self::AppKvWrite(_) => Scope::ScriptWrite,
|
Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) => {
|
||||||
|
Scope::ScriptWrite
|
||||||
|
}
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => {
|
Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => {
|
||||||
@@ -253,7 +265,10 @@ async fn member_grants(
|
|||||||
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
let in_viewer = matches!(
|
let in_viewer = matches!(
|
||||||
cap,
|
cap,
|
||||||
Capability::AppRead(_) | Capability::AppLogRead(_) | Capability::AppKvRead(_)
|
Capability::AppRead(_)
|
||||||
|
| Capability::AppLogRead(_)
|
||||||
|
| Capability::AppKvRead(_)
|
||||||
|
| Capability::AppDocsRead(_)
|
||||||
);
|
);
|
||||||
let in_editor = in_viewer
|
let in_editor = in_viewer
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@@ -261,6 +276,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
Capability::AppWriteScript(_)
|
Capability::AppWriteScript(_)
|
||||||
| Capability::AppWriteRoute(_)
|
| Capability::AppWriteRoute(_)
|
||||||
| Capability::AppKvWrite(_)
|
| Capability::AppKvWrite(_)
|
||||||
|
| Capability::AppDocsWrite(_)
|
||||||
);
|
);
|
||||||
let in_app_admin = in_editor
|
let in_app_admin = in_editor
|
||||||
|| matches!(
|
|| matches!(
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ impl Dispatcher {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
OutboxSourceKind::Kv | OutboxSourceKind::DeadLetter => {
|
OutboxSourceKind::Kv | OutboxSourceKind::Docs | OutboxSourceKind::DeadLetter => {
|
||||||
let resolved = self.resolve_trigger(&row).await?;
|
let resolved = self.resolve_trigger(&row).await?;
|
||||||
let req = match self.build_exec_request(&row, &resolved).await {
|
let req = match self.build_exec_request(&row, &resolved).await {
|
||||||
Ok(req) => req,
|
Ok(req) => req,
|
||||||
|
|||||||
598
crates/manager-core/src/docs_filter.rs
Normal file
598
crates/manager-core/src/docs_filter.rs
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
//! v1.1.2 query DSL parser + AST for `docs::find` / `docs::find_one`.
|
||||||
|
//!
|
||||||
|
//! Sets the precedent v1.2's `dead_letters::list` will follow (see
|
||||||
|
//! `docs/v1.1.x-design-notes.md` §4 #13). When that lands we promote
|
||||||
|
//! this module to `picloud-shared` and rename to
|
||||||
|
//! `picloud_shared::query::{Filter, FieldPath, ComparisonOp}`; until
|
||||||
|
//! then keeping it private to manager-core avoids over-engineering.
|
||||||
|
//!
|
||||||
|
//! Parse stage is deliberately strict: any unrecognized `$xxx`
|
||||||
|
//! operator surfaces as `FilterParseError::UnsupportedOperator` with
|
||||||
|
//! a script-visible message naming the offending key + pointing at
|
||||||
|
//! v1.2. The error strings become part of the SDK contract once
|
||||||
|
//! scripts depend on them; pin them with snapshot tests in the test
|
||||||
|
//! module below before changing.
|
||||||
|
//!
|
||||||
|
//! ## DSL surface (v1.1.2 subset)
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! // implicit equality (top-level)
|
||||||
|
//! users.find(#{ tier: "gold", status: "active" })
|
||||||
|
//!
|
||||||
|
//! // operator object on a field
|
||||||
|
//! users.find(#{ created_at: #{ "$gt": "2026-01-01T00:00:00Z" } })
|
||||||
|
//!
|
||||||
|
//! // dotted paths (max 5 segments)
|
||||||
|
//! users.find(#{ "user.email": "a@b" })
|
||||||
|
//!
|
||||||
|
//! // sort + limit as filter modifiers
|
||||||
|
//! users.find(#{ tier: "gold", "$sort": #{ created_at: -1 }, "$limit": 10 })
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Out of scope (v1.2)
|
||||||
|
//!
|
||||||
|
//! `$or`, `$and`, `$not`, `$exists`, `$regex`, `$type`, `$size`,
|
||||||
|
//! `$all`, `$elemMatch`, multi-field sort, projection, aggregations.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Maximum nesting depth for dotted field paths. `"a.b.c.d.e"` is the
|
||||||
|
/// deepest path allowed (5 segments). Deeper paths reject at parse
|
||||||
|
/// time with `InvalidFilter` — prevents pathological JSONB navigation
|
||||||
|
/// chains from a script.
|
||||||
|
pub const MAX_FIELD_PATH_DEPTH: usize = 5;
|
||||||
|
|
||||||
|
/// Hard cap on `$limit` values — script-side limits are silently
|
||||||
|
/// clamped here so the Postgres query is always bounded. Mirrors the
|
||||||
|
/// `find` repo's own internal cap.
|
||||||
|
pub const MAX_FIND_LIMIT: u32 = 1_000;
|
||||||
|
|
||||||
|
/// Parsed `docs::find` filter.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct DocsFilter {
|
||||||
|
pub conditions: Vec<FieldCondition>,
|
||||||
|
pub sort: Option<Sort>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocsFilter {
|
||||||
|
/// Empty filter — matches every document in the collection.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
conditions: Vec::new(),
|
||||||
|
sort: None,
|
||||||
|
limit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct FieldCondition {
|
||||||
|
pub path: FieldPath,
|
||||||
|
pub op: ComparisonOp,
|
||||||
|
pub value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated dotted path. Construct only via `FieldPath::parse` so the
|
||||||
|
/// segment invariants (non-empty, no `..`, no `$` prefix, depth ≤ 5)
|
||||||
|
/// are guaranteed.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FieldPath {
|
||||||
|
segments: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldPath {
|
||||||
|
/// Parse a dotted path from a JSON object key.
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, FilterParseError> {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(FilterParseError::InvalidFilter(
|
||||||
|
"docs::find: field path must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let segments: Vec<&str> = raw.split('.').collect();
|
||||||
|
if segments.len() > MAX_FIELD_PATH_DEPTH {
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: field path '{raw}' exceeds max depth {MAX_FIELD_PATH_DEPTH}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for seg in &segments {
|
||||||
|
if seg.is_empty() {
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: field path '{raw}' has an empty segment (leading/trailing dot or '..')"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if seg.starts_with('$') {
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: field path segment '{seg}' must not start with '$'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
segments: segments.into_iter().map(ToString::to_string).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path segments in order. The Postgres impl binds each as a
|
||||||
|
/// separate text parameter to `jsonb_extract_path_text`, so no
|
||||||
|
/// segment ever appears in the SQL string verbatim.
|
||||||
|
#[must_use]
|
||||||
|
pub fn segments(&self) -> &[String] {
|
||||||
|
&self.segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display form for error messages — joined back with `.`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> String {
|
||||||
|
self.segments.join(".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ComparisonOp {
|
||||||
|
/// Implicit equality at top level OR explicit `$eq`. Maps to
|
||||||
|
/// `jsonb_extract_path_text(...) = $M`.
|
||||||
|
Eq,
|
||||||
|
/// `$ne` — uses Postgres `IS DISTINCT FROM` so JSON nulls and
|
||||||
|
/// missing paths are correctly included (`<>` returns NULL on
|
||||||
|
/// either operand being NULL, which would silently exclude rows
|
||||||
|
/// the user expects to see).
|
||||||
|
Ne,
|
||||||
|
/// `$gt` / `$gte` / `$lt` / `$lte` — text-lex comparison per the
|
||||||
|
/// brief's contract. Known limitation: lex breaks across
|
||||||
|
/// digit-count boundaries (`'10' < '9'` is TRUE). Documented in
|
||||||
|
/// CHANGELOG; v1.2 advanced query will add numeric-aware
|
||||||
|
/// operators.
|
||||||
|
Gt,
|
||||||
|
Gte,
|
||||||
|
Lt,
|
||||||
|
Lte,
|
||||||
|
/// `$in` — `= ANY($M::text[])` where the value list is bound as
|
||||||
|
/// a TEXT[].
|
||||||
|
In,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComparisonOp {
|
||||||
|
/// Decode an operator key like `"$gt"`. Returns `None` for any
|
||||||
|
/// non-`$` key; returns `Some(Err(...))` for `$`-prefixed keys
|
||||||
|
/// not in the v1.1.2 allowlist (caller surfaces the
|
||||||
|
/// UnsupportedOperator error).
|
||||||
|
fn from_dollar_key(key: &str) -> Option<Result<Self, FilterParseError>> {
|
||||||
|
if !key.starts_with('$') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match key {
|
||||||
|
"$eq" => Ok(Self::Eq),
|
||||||
|
"$ne" => Ok(Self::Ne),
|
||||||
|
"$gt" => Ok(Self::Gt),
|
||||||
|
"$gte" => Ok(Self::Gte),
|
||||||
|
"$lt" => Ok(Self::Lt),
|
||||||
|
"$lte" => Ok(Self::Lte),
|
||||||
|
"$in" => Ok(Self::In),
|
||||||
|
other => Err(FilterParseError::UnsupportedOperator(format!(
|
||||||
|
"docs::find: operator '{other}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||||
|
))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Sort {
|
||||||
|
pub path: FieldPath,
|
||||||
|
pub direction: SortDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SortDir {
|
||||||
|
Asc,
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FilterParseError {
|
||||||
|
/// Bad path syntax, malformed operator value, multi-field sort,
|
||||||
|
/// etc. The string is the script-visible message.
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidFilter(String),
|
||||||
|
|
||||||
|
/// Filter used an operator not in the v1.1.2 allowlist. The
|
||||||
|
/// string includes the offending operator + v1.2 pointer.
|
||||||
|
#[error("{0}")]
|
||||||
|
UnsupportedOperator(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `serde_json::Value` filter into `DocsFilter`. The bridge
|
||||||
|
/// converts the script's Rhai map into a `Value` via
|
||||||
|
/// `executor-core::sdk::bridge::dynamic_to_json` and passes it through
|
||||||
|
/// `DocsService::find`; the service calls this parser before touching
|
||||||
|
/// the repo.
|
||||||
|
pub fn parse_filter(filter: &Value) -> Result<DocsFilter, FilterParseError> {
|
||||||
|
let obj = filter.as_object().ok_or_else(|| {
|
||||||
|
FilterParseError::InvalidFilter("docs::find: filter must be a map/object".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut out = DocsFilter::empty();
|
||||||
|
|
||||||
|
for (key, value) in obj {
|
||||||
|
if let Some(stripped) = key.strip_prefix('$') {
|
||||||
|
// Top-level modifier — `$sort` / `$limit`. Any other
|
||||||
|
// dollar-key at top level is unsupported.
|
||||||
|
match stripped {
|
||||||
|
"sort" => out.sort = Some(parse_sort(value)?),
|
||||||
|
"limit" => out.limit = Some(parse_limit(value)?),
|
||||||
|
other => {
|
||||||
|
return Err(FilterParseError::UnsupportedOperator(format!(
|
||||||
|
"docs::find: top-level modifier '${other}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field path → either implicit equality OR operator-object.
|
||||||
|
let path = FieldPath::parse(key)?;
|
||||||
|
match value {
|
||||||
|
Value::Object(inner) if is_operator_object(inner) => {
|
||||||
|
for (op_key, op_val) in inner {
|
||||||
|
let Some(op_res) = ComparisonOp::from_dollar_key(op_key) else {
|
||||||
|
// This shouldn't trigger — is_operator_object
|
||||||
|
// already guarantees every key is $-prefixed.
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: operator object for '{}' has non-$ key '{op_key}'",
|
||||||
|
path.as_str()
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let op = op_res?;
|
||||||
|
validate_op_value(op, op_val, &path)?;
|
||||||
|
out.conditions.push(FieldCondition {
|
||||||
|
path: path.clone(),
|
||||||
|
op,
|
||||||
|
value: op_val.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Any non-object value is implicit equality.
|
||||||
|
// (Object values with non-$ keys are user data, not an
|
||||||
|
// operator object — reject so the user doesn't accidentally
|
||||||
|
// match against a literal `{ name: "Alice" }` shape that
|
||||||
|
// would never compare meaningfully under JSONB text.)
|
||||||
|
Value::Object(_) => {
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: value for '{}' must be a scalar (implicit equality) or an operator map (keys starting with '$')",
|
||||||
|
path.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
out.conditions.push(FieldCondition {
|
||||||
|
path,
|
||||||
|
op: ComparisonOp::Eq,
|
||||||
|
value: value.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when every key in the map starts with `$`. Mixed-shape maps
|
||||||
|
/// (some `$key`, some user-data key) are rejected to avoid silent
|
||||||
|
/// surprise — the user almost certainly meant an operator object.
|
||||||
|
fn is_operator_object(map: &serde_json::Map<String, Value>) -> bool {
|
||||||
|
!map.is_empty() && map.keys().all(|k| k.starts_with('$'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_op_value(
|
||||||
|
op: ComparisonOp,
|
||||||
|
value: &Value,
|
||||||
|
path: &FieldPath,
|
||||||
|
) -> Result<(), FilterParseError> {
|
||||||
|
match op {
|
||||||
|
ComparisonOp::In => {
|
||||||
|
if !value.is_array() {
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: '$in' on '{}' requires an array value",
|
||||||
|
path.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// For the scalar-comparison ops, the value must be a JSON
|
||||||
|
// scalar (no arrays / no nested objects). JSON null is
|
||||||
|
// allowed — `$ne` against null is a valid query.
|
||||||
|
if value.is_array() || value.is_object() {
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: '{op_name}' on '{path}' requires a scalar value",
|
||||||
|
op_name = op_name(op),
|
||||||
|
path = path.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn op_name(op: ComparisonOp) -> &'static str {
|
||||||
|
match op {
|
||||||
|
ComparisonOp::Eq => "$eq",
|
||||||
|
ComparisonOp::Ne => "$ne",
|
||||||
|
ComparisonOp::Gt => "$gt",
|
||||||
|
ComparisonOp::Gte => "$gte",
|
||||||
|
ComparisonOp::Lt => "$lt",
|
||||||
|
ComparisonOp::Lte => "$lte",
|
||||||
|
ComparisonOp::In => "$in",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sort(value: &Value) -> Result<Sort, FilterParseError> {
|
||||||
|
let map = value.as_object().ok_or_else(|| {
|
||||||
|
FilterParseError::InvalidFilter("docs::find: '$sort' must be a map".into())
|
||||||
|
})?;
|
||||||
|
if map.is_empty() {
|
||||||
|
return Err(FilterParseError::InvalidFilter(
|
||||||
|
"docs::find: '$sort' must name at least one field".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if map.len() > 1 {
|
||||||
|
return Err(FilterParseError::InvalidFilter(
|
||||||
|
"docs::find: multi-field '$sort' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (field, dir_val) = map.iter().next().unwrap();
|
||||||
|
let path = FieldPath::parse(field)?;
|
||||||
|
let direction = match dir_val.as_i64() {
|
||||||
|
Some(1) => SortDir::Asc,
|
||||||
|
Some(-1) => SortDir::Desc,
|
||||||
|
_ => {
|
||||||
|
return Err(FilterParseError::InvalidFilter(format!(
|
||||||
|
"docs::find: '$sort' direction for '{field}' must be 1 (ascending) or -1 (descending)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Sort { path, direction })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_limit(value: &Value) -> Result<u32, FilterParseError> {
|
||||||
|
let n = value.as_i64().ok_or_else(|| {
|
||||||
|
FilterParseError::InvalidFilter("docs::find: '$limit' must be an integer".into())
|
||||||
|
})?;
|
||||||
|
if n < 0 {
|
||||||
|
return Err(FilterParseError::InvalidFilter(
|
||||||
|
"docs::find: '$limit' must be non-negative".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(u32::try_from(n)
|
||||||
|
.unwrap_or(MAX_FIND_LIMIT)
|
||||||
|
.min(MAX_FIND_LIMIT))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests — error messages are part of the SDK contract once scripts
|
||||||
|
// depend on them; the snapshot-style asserts pin the exact strings.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn parse(v: Value) -> Result<DocsFilter, FilterParseError> {
|
||||||
|
parse_filter(&v)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_object_has_no_conditions() {
|
||||||
|
let f = parse(json!({})).unwrap();
|
||||||
|
assert!(f.conditions.is_empty());
|
||||||
|
assert!(f.sort.is_none());
|
||||||
|
assert!(f.limit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_equality_top_level() {
|
||||||
|
let f = parse(json!({ "tier": "gold" })).unwrap();
|
||||||
|
assert_eq!(f.conditions.len(), 1);
|
||||||
|
assert_eq!(f.conditions[0].path.segments(), &["tier".to_string()]);
|
||||||
|
assert_eq!(f.conditions[0].op, ComparisonOp::Eq);
|
||||||
|
assert_eq!(f.conditions[0].value, json!("gold"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_field_equality_is_conjunctive() {
|
||||||
|
let f = parse(json!({ "tier": "gold", "status": "active" })).unwrap();
|
||||||
|
assert_eq!(f.conditions.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_dotted_path() {
|
||||||
|
let f = parse(json!({ "user.email": "a@b" })).unwrap();
|
||||||
|
let cond = &f.conditions[0];
|
||||||
|
assert_eq!(
|
||||||
|
cond.path.segments(),
|
||||||
|
&["user".to_string(), "email".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depth_limit_rejects_six_segments() {
|
||||||
|
let err = parse(json!({ "a.b.c.d.e.f": "x" })).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("exceeds max depth"), "msg: {msg}");
|
||||||
|
assert!(msg.contains('5'), "msg: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_dot_rejected() {
|
||||||
|
let err = parse(json!({ "a..b": "x" })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("empty segment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leading_dot_rejected() {
|
||||||
|
let err = parse(json!({ ".a": "x" })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("empty segment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_dot_rejected() {
|
||||||
|
let err = parse(json!({ "a.": "x" })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("empty segment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollar_prefix_in_path_segment_rejected() {
|
||||||
|
// (The top-level $foo would route to operator dispatch; this
|
||||||
|
// tests deeper segments which should never start with $.)
|
||||||
|
let err = parse(json!({ "x.$inner": "v" })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("must not start with '$'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_supported_operator_parses() {
|
||||||
|
for (key, expected_op) in [
|
||||||
|
("$eq", ComparisonOp::Eq),
|
||||||
|
("$ne", ComparisonOp::Ne),
|
||||||
|
("$gt", ComparisonOp::Gt),
|
||||||
|
("$gte", ComparisonOp::Gte),
|
||||||
|
("$lt", ComparisonOp::Lt),
|
||||||
|
("$lte", ComparisonOp::Lte),
|
||||||
|
] {
|
||||||
|
let v = json!({ "field": { key: "v" } });
|
||||||
|
let f = parse(v).unwrap();
|
||||||
|
assert_eq!(f.conditions[0].op, expected_op, "key {key}");
|
||||||
|
}
|
||||||
|
// $in needs an array.
|
||||||
|
let f = parse(json!({ "tier": { "$in": ["gold", "platinum"] } })).unwrap();
|
||||||
|
assert_eq!(f.conditions[0].op, ComparisonOp::In);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollar_in_with_non_array_value_rejected() {
|
||||||
|
let err = parse(json!({ "tier": { "$in": "gold" } })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("'$in'"));
|
||||||
|
assert!(err.to_string().contains("array"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_op_with_object_value_rejected() {
|
||||||
|
let err = parse(json!({ "tier": { "$gt": { "nested": true } } })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("'$gt'"));
|
||||||
|
assert!(err.to_string().contains("scalar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot: the v1.2-deferred operator error string is part of
|
||||||
|
/// the SDK contract. Don't change it without a major-version bump.
|
||||||
|
#[test]
|
||||||
|
fn unsupported_operator_message_pins_v1_2_pointer() {
|
||||||
|
let err = parse(json!({ "name": { "$regex": "^A" } })).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"docs::find: operator '$regex' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_top_level_modifier_rejected() {
|
||||||
|
let err = parse(json!({ "$or": [{ "x": 1 }] })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("'$or'"));
|
||||||
|
assert!(err.to_string().contains("v1.2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot: depth-limit error string. Pinned per the SDK contract.
|
||||||
|
#[test]
|
||||||
|
fn depth_limit_message_pinned() {
|
||||||
|
let err = parse(json!({ "a.b.c.d.e.f": 1 })).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"docs::find: field path 'a.b.c.d.e.f' exceeds max depth 5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_shape_operator_object_rejected() {
|
||||||
|
// Object value where some keys are $-prefixed and some aren't
|
||||||
|
// — treated as user data + invalid (the user almost certainly
|
||||||
|
// meant an operator object).
|
||||||
|
let err = parse(json!({ "x": { "$gt": 1, "other": 2 } })).unwrap_err();
|
||||||
|
assert!(err
|
||||||
|
.to_string()
|
||||||
|
.contains("scalar (implicit equality) or an operator map"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_asc_and_desc_parse() {
|
||||||
|
let f = parse(json!({ "$sort": { "created_at": 1 } })).unwrap();
|
||||||
|
let sort = f.sort.unwrap();
|
||||||
|
assert_eq!(sort.direction, SortDir::Asc);
|
||||||
|
assert_eq!(sort.path.segments(), &["created_at".to_string()]);
|
||||||
|
|
||||||
|
let f = parse(json!({ "$sort": { "created_at": -1 } })).unwrap();
|
||||||
|
assert_eq!(f.sort.unwrap().direction, SortDir::Desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_with_bad_direction_rejected() {
|
||||||
|
let err = parse(json!({ "$sort": { "x": 2 } })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("1 (ascending)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot: multi-field sort error string. Pinned.
|
||||||
|
#[test]
|
||||||
|
fn multi_field_sort_rejected_with_v1_2_pointer() {
|
||||||
|
let err = parse(json!({ "$sort": { "a": 1, "b": -1 } })).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"docs::find: multi-field '$sort' is not supported in v1.1.2; planned for v1.2 advanced query"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_accepts_non_negative_integer() {
|
||||||
|
let f = parse(json!({ "$limit": 50 })).unwrap();
|
||||||
|
assert_eq!(f.limit, Some(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_clamps_to_max() {
|
||||||
|
let f = parse(json!({ "$limit": 10_000 })).unwrap();
|
||||||
|
assert_eq!(f.limit, Some(MAX_FIND_LIMIT));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_rejects_negative() {
|
||||||
|
let err = parse(json!({ "$limit": -1 })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("non-negative"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_rejects_non_integer() {
|
||||||
|
let err = parse(json!({ "$limit": "twenty" })).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("integer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_object_filter_rejected() {
|
||||||
|
let err = parse(json!("not a map")).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("filter must be a map/object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollar_eq_value_can_be_null() {
|
||||||
|
// $ne against null is a valid query (returns docs where field
|
||||||
|
// exists and is not null OR is missing) — so null must be an
|
||||||
|
// accepted scalar.
|
||||||
|
let f = parse(json!({ "deleted_at": { "$ne": null } })).unwrap();
|
||||||
|
assert_eq!(f.conditions[0].op, ComparisonOp::Ne);
|
||||||
|
assert_eq!(f.conditions[0].value, Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn implicit_equality_with_array_value_accepts() {
|
||||||
|
// `{ "tags": ["a", "b"] }` is implicit equality against the
|
||||||
|
// literal array shape. The Postgres query will compare the
|
||||||
|
// text encoding under JSONB; this is valid v1.1.2.
|
||||||
|
let f = parse(json!({ "tags": ["a", "b"] })).unwrap();
|
||||||
|
assert_eq!(f.conditions[0].op, ComparisonOp::Eq);
|
||||||
|
}
|
||||||
|
}
|
||||||
556
crates/manager-core/src/docs_repo.rs
Normal file
556
crates/manager-core/src/docs_repo.rs
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
//! Low-level Postgres CRUD + filter-query builder over the `docs`
|
||||||
|
//! table (migration 0013). Stays storage-only; authorization, event
|
||||||
|
//! emission, and empty-collection validation live one layer up in
|
||||||
|
//! `DocsServiceImpl`.
|
||||||
|
//!
|
||||||
|
//! The `find` SQL builder is the security-critical surface. **Every
|
||||||
|
//! field-path segment and every comparison value is bound as a
|
||||||
|
//! `$N` parameter — never interpolated into the SQL string.** The base
|
||||||
|
//! `WHERE app_id = $1 AND collection = $2` clause is fixed and
|
||||||
|
//! prepended to every query so cross-app isolation can't be widened by
|
||||||
|
//! any operator. See `sql_starts_with_app_collection_predicate`
|
||||||
|
//! assertion in tests for the load-bearing guarantee.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AppId, DocId, DocRow, DocsListPage};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::postgres::PgRow;
|
||||||
|
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::docs_filter::{ComparisonOp, DocsFilter, SortDir};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DocsRepoError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("invalid pagination cursor")]
|
||||||
|
InvalidCursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo surface. The trait is exposed so the service unit tests can
|
||||||
|
/// substitute an in-memory backing without spinning up Postgres.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DocsRepo: Send + Sync {
|
||||||
|
/// Create a new doc with a server-generated UUID. Returns the
|
||||||
|
/// fully-materialised `DocRow` so the caller has timestamps too
|
||||||
|
/// (no separate select-back round-trip).
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
data: Value,
|
||||||
|
) -> Result<DocRow, DocsRepoError>;
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<DocRow>, DocsRepoError>;
|
||||||
|
|
||||||
|
/// Filter-based query. The parsed `DocsFilter` ensures every
|
||||||
|
/// field-path segment and operator value is bound as a parameter.
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
filter: &DocsFilter,
|
||||||
|
) -> Result<Vec<DocRow>, DocsRepoError>;
|
||||||
|
|
||||||
|
/// Full document replace. Returns `Some(previous_data)` on
|
||||||
|
/// success, `None` if no doc matched (the service maps that to
|
||||||
|
/// `DocsError::NotFound`). The prev value is the input to the
|
||||||
|
/// emitted update event's `old_payload`.
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
data: Value,
|
||||||
|
) -> Result<Option<Value>, DocsRepoError>;
|
||||||
|
|
||||||
|
/// Returns the deleted doc's data if it existed, `None` if no
|
||||||
|
/// such doc. The caller converts `Some` → `Ok(true)` for the SDK's
|
||||||
|
/// was-present return; the `Value` feeds the delete event's
|
||||||
|
/// `old_payload`.
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<Value>, DocsRepoError>;
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<DocsListPage, DocsRepoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresDocsRepo {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresDocsRepo {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard ceiling on `list` page size — mirrors KV's `KV_LIST_MAX_LIMIT`.
|
||||||
|
/// Scripts that pass anything larger get silently clamped.
|
||||||
|
const DOCS_LIST_MAX_LIMIT: u32 = 1_000;
|
||||||
|
const DOCS_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DocsRepo for PostgresDocsRepo {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
data: Value,
|
||||||
|
) -> Result<DocRow, DocsRepoError> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
|
||||||
|
"INSERT INTO docs (app_id, collection, id, data) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
RETURNING created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&data)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(DocRow {
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
created_at: row.0,
|
||||||
|
updated_at: row.1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<DocRow>, DocsRepoError> {
|
||||||
|
let row: Option<(Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||||
|
"SELECT data, created_at, updated_at FROM docs \
|
||||||
|
WHERE app_id = $1 AND collection = $2 AND id = $3",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(data, created_at, updated_at)| DocRow {
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
filter: &DocsFilter,
|
||||||
|
) -> Result<Vec<DocRow>, DocsRepoError> {
|
||||||
|
let mut qb = build_find_query(app_id, collection, filter);
|
||||||
|
let rows = qb.build().fetch_all(&self.pool).await?;
|
||||||
|
rows.into_iter().map(row_to_doc).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
data: Value,
|
||||||
|
) -> Result<Option<Value>, DocsRepoError> {
|
||||||
|
// Same CTE shape as KV's set ([kv_repo.rs:101-132]): SELECT the
|
||||||
|
// previous data before the UPDATE so the service can emit
|
||||||
|
// `prev_data` in the update ServiceEvent. Single statement, no
|
||||||
|
// explicit transaction. Inherits KV's last-writer-wins race
|
||||||
|
// under concurrent writers; documented as a known limitation
|
||||||
|
// for v1.1.2.
|
||||||
|
let row: Option<(Option<Value>,)> = sqlx::query_as(
|
||||||
|
"WITH prev AS ( \
|
||||||
|
SELECT data FROM docs \
|
||||||
|
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||||
|
), \
|
||||||
|
updated AS ( \
|
||||||
|
UPDATE docs SET data = $4, updated_at = NOW() \
|
||||||
|
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||||
|
RETURNING 1 \
|
||||||
|
) \
|
||||||
|
SELECT (SELECT data FROM prev) FROM updated",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&data)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
// `row` is None when the UPDATE matched no rows (missing doc);
|
||||||
|
// Some((Some(prev),)) on success. `data` is JSONB NOT NULL so
|
||||||
|
// the inner Option is always Some when prev exists.
|
||||||
|
Ok(row.and_then(|(v,)| v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<Value>, DocsRepoError> {
|
||||||
|
let row: Option<(Value,)> = sqlx::query_as(
|
||||||
|
"DELETE FROM docs \
|
||||||
|
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||||||
|
RETURNING data",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(v,)| v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<DocsListPage, DocsRepoError> {
|
||||||
|
let limit = if limit == 0 {
|
||||||
|
DOCS_LIST_DEFAULT_LIMIT
|
||||||
|
} else {
|
||||||
|
limit.min(DOCS_LIST_MAX_LIMIT)
|
||||||
|
};
|
||||||
|
|
||||||
|
let last_id = match cursor {
|
||||||
|
Some(c) => Some(decode_cursor(c)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let take = i64::from(limit) + 1;
|
||||||
|
let rows: Vec<(Uuid, Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||||
|
"SELECT id, data, created_at, updated_at FROM docs \
|
||||||
|
WHERE app_id = $1 AND collection = $2 \
|
||||||
|
AND ($3::uuid IS NULL OR id > $3) \
|
||||||
|
ORDER BY id ASC \
|
||||||
|
LIMIT $4",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(last_id)
|
||||||
|
.bind(take)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut docs: Vec<DocRow> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, data, created_at, updated_at)| DocRow {
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let next_cursor = if docs.len() > limit as usize {
|
||||||
|
docs.truncate(limit as usize);
|
||||||
|
docs.last().map(|d| encode_cursor(&d.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(DocsListPage { docs, next_cursor })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
|
||||||
|
Ok(DocRow {
|
||||||
|
id: row.try_get("id")?,
|
||||||
|
data: row.try_get("data")?,
|
||||||
|
created_at: row.try_get("created_at")?,
|
||||||
|
updated_at: row.try_get("updated_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_cursor(last_id: &Uuid) -> String {
|
||||||
|
URL_SAFE_NO_PAD.encode(last_id.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cursor(cursor: &str) -> Result<Uuid, DocsRepoError> {
|
||||||
|
let bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(cursor)
|
||||||
|
.map_err(|_| DocsRepoError::InvalidCursor)?;
|
||||||
|
let arr: [u8; 16] = bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| DocsRepoError::InvalidCursor)?;
|
||||||
|
Ok(Uuid::from_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// SQL builder — the load-bearing security surface.
|
||||||
|
//
|
||||||
|
// Every field-path segment + every comparison value goes through
|
||||||
|
// `QueryBuilder::push_bind`, which appends `$N` to the SQL string and
|
||||||
|
// binds the value as a parameter. The only literal strings appended to
|
||||||
|
// the SQL are: hardcoded SQL fragments (SELECT/WHERE/AND/etc.) and
|
||||||
|
// hardcoded operator strings ("=", "IS DISTINCT FROM", ">", "ASC", …).
|
||||||
|
// **No user input ever lands in the SQL text unparameterized.**
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn build_find_query<'a>(
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &'a str,
|
||||||
|
filter: &'a DocsFilter,
|
||||||
|
) -> QueryBuilder<'a, Postgres> {
|
||||||
|
let mut qb =
|
||||||
|
QueryBuilder::new("SELECT id, data, created_at, updated_at FROM docs WHERE app_id = ");
|
||||||
|
qb.push_bind(app_id.into_inner());
|
||||||
|
qb.push(" AND collection = ");
|
||||||
|
qb.push_bind(collection);
|
||||||
|
|
||||||
|
for cond in &filter.conditions {
|
||||||
|
qb.push(" AND ");
|
||||||
|
emit_condition(&mut qb, cond);
|
||||||
|
}
|
||||||
|
|
||||||
|
qb.push(" ORDER BY ");
|
||||||
|
if let Some(sort) = &filter.sort {
|
||||||
|
push_jsonb_path(&mut qb, sort.path.segments());
|
||||||
|
qb.push(match sort.direction {
|
||||||
|
SortDir::Asc => " ASC",
|
||||||
|
SortDir::Desc => " DESC",
|
||||||
|
});
|
||||||
|
qb.push(", id ASC");
|
||||||
|
} else {
|
||||||
|
qb.push("id ASC");
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = filter
|
||||||
|
.limit
|
||||||
|
.map_or(DOCS_LIST_MAX_LIMIT, |l| l.min(DOCS_LIST_MAX_LIMIT));
|
||||||
|
qb.push(" LIMIT ");
|
||||||
|
qb.push_bind(i64::from(limit));
|
||||||
|
|
||||||
|
qb
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_condition<'a>(
|
||||||
|
qb: &mut QueryBuilder<'a, Postgres>,
|
||||||
|
cond: &'a crate::docs_filter::FieldCondition,
|
||||||
|
) {
|
||||||
|
push_jsonb_path(qb, cond.path.segments());
|
||||||
|
match cond.op {
|
||||||
|
ComparisonOp::Eq => {
|
||||||
|
if cond.value.is_null() {
|
||||||
|
qb.push(" IS NULL");
|
||||||
|
} else {
|
||||||
|
qb.push(" = ");
|
||||||
|
qb.push_bind(value_to_text(&cond.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ComparisonOp::Ne => {
|
||||||
|
// IS DISTINCT FROM correctly handles NULL on either side
|
||||||
|
// (would otherwise silently exclude rows with missing
|
||||||
|
// paths). Holds for the literal-NULL case too.
|
||||||
|
if cond.value.is_null() {
|
||||||
|
qb.push(" IS NOT NULL");
|
||||||
|
} else {
|
||||||
|
qb.push(" IS DISTINCT FROM ");
|
||||||
|
qb.push_bind(value_to_text(&cond.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ComparisonOp::Gt => {
|
||||||
|
qb.push(" > ");
|
||||||
|
qb.push_bind(value_to_text(&cond.value));
|
||||||
|
}
|
||||||
|
ComparisonOp::Gte => {
|
||||||
|
qb.push(" >= ");
|
||||||
|
qb.push_bind(value_to_text(&cond.value));
|
||||||
|
}
|
||||||
|
ComparisonOp::Lt => {
|
||||||
|
qb.push(" < ");
|
||||||
|
qb.push_bind(value_to_text(&cond.value));
|
||||||
|
}
|
||||||
|
ComparisonOp::Lte => {
|
||||||
|
qb.push(" <= ");
|
||||||
|
qb.push_bind(value_to_text(&cond.value));
|
||||||
|
}
|
||||||
|
ComparisonOp::In => {
|
||||||
|
qb.push(" = ANY(");
|
||||||
|
let texts: Vec<Option<String>> = cond
|
||||||
|
.value
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| arr.iter().map(value_to_text).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
qb.push_bind(texts);
|
||||||
|
qb.push(")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `jsonb_extract_path_text(data, $N1, $N2, …)` with each
|
||||||
|
/// segment bound as a separate text parameter. Variadic path lengths
|
||||||
|
/// (1–5) all flow through this single helper.
|
||||||
|
fn push_jsonb_path<'a>(qb: &mut QueryBuilder<'a, Postgres>, segments: &'a [String]) {
|
||||||
|
qb.push("jsonb_extract_path_text(data");
|
||||||
|
for seg in segments {
|
||||||
|
qb.push(", ");
|
||||||
|
qb.push_bind(seg.as_str());
|
||||||
|
}
|
||||||
|
qb.push(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON scalar → TEXT for binding. `Value::Null` is preserved as
|
||||||
|
/// `None` so the binding lands as SQL NULL (handled specially above for
|
||||||
|
/// `Eq` / `Ne`). Arrays + objects serialize to compact JSON; the user
|
||||||
|
/// is comparing against the JSONB text rendering, which is consistent
|
||||||
|
/// with `jsonb_extract_path_text`'s output for those types.
|
||||||
|
fn value_to_text(v: &Value) -> Option<String> {
|
||||||
|
match v {
|
||||||
|
Value::Null => None,
|
||||||
|
Value::String(s) => Some(s.clone()),
|
||||||
|
Value::Bool(b) => Some(b.to_string()),
|
||||||
|
Value::Number(n) => Some(n.to_string()),
|
||||||
|
Value::Array(_) | Value::Object(_) => Some(v.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// SQL-shape guardrail tests — pure (no DB) so they run in the default
|
||||||
|
// test suite. These are the highest-stakes tests in the release: they
|
||||||
|
// pin the cross-app isolation invariant at the SQL level.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sql_shape_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::docs_filter::parse_filter;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn sql_for(filter_json: serde_json::Value) -> String {
|
||||||
|
let filter = parse_filter(&filter_json).unwrap();
|
||||||
|
let qb = build_find_query(AppId::new(), "users", &filter);
|
||||||
|
qb.sql().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Load-bearing**: every generated SELECT begins
|
||||||
|
/// `WHERE app_id = $1 AND collection = $2`. The app_id parameter
|
||||||
|
/// is the cross-app isolation gate. No user-supplied filter
|
||||||
|
/// fragment can ever appear before these clauses.
|
||||||
|
#[test]
|
||||||
|
fn every_query_starts_with_app_id_and_collection_predicate() {
|
||||||
|
let cases = vec![
|
||||||
|
json!({}),
|
||||||
|
json!({ "tier": "gold" }),
|
||||||
|
json!({ "created_at": { "$gt": "2026-01-01" } }),
|
||||||
|
json!({ "tier": { "$in": ["gold", "platinum"] } }),
|
||||||
|
json!({ "tier": "gold", "status": "active" }),
|
||||||
|
json!({ "$sort": { "created_at": -1 }, "$limit": 5 }),
|
||||||
|
json!({ "tier": "gold", "$sort": { "created_at": 1 } }),
|
||||||
|
json!({ "deleted_at": { "$ne": null } }),
|
||||||
|
];
|
||||||
|
for case in cases {
|
||||||
|
let sql = sql_for(case.clone());
|
||||||
|
assert!(
|
||||||
|
sql.starts_with(
|
||||||
|
"SELECT id, data, created_at, updated_at FROM docs WHERE app_id = $1 AND collection = $2"
|
||||||
|
),
|
||||||
|
"filter {case} produced SQL: {sql}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every comparison value lands as a `$N` placeholder — there
|
||||||
|
/// should be NO double-quoted string literal in the SQL after the
|
||||||
|
/// fixed prefix. (This guards against an accidental `format!`
|
||||||
|
/// regression.)
|
||||||
|
#[test]
|
||||||
|
fn no_user_string_literal_in_sql() {
|
||||||
|
let sql = sql_for(json!({ "tier": "gold; DROP TABLE docs;--" }));
|
||||||
|
assert!(!sql.contains("gold"), "value leaked into SQL string: {sql}");
|
||||||
|
assert!(!sql.contains("DROP"), "value leaked into SQL string: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Field-path segments also bind as parameters. A user passing a
|
||||||
|
/// path that looks like SQL keywords doesn't change the structure.
|
||||||
|
#[test]
|
||||||
|
fn no_user_path_literal_in_sql() {
|
||||||
|
let sql = sql_for(json!({ "drop_table_users": "v" }));
|
||||||
|
assert!(
|
||||||
|
!sql.contains("drop_table_users"),
|
||||||
|
"path leaked into SQL string: {sql}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_filter_sql_has_no_extra_conditions() {
|
||||||
|
let sql = sql_for(json!({}));
|
||||||
|
// After the fixed prefix, only ORDER BY + LIMIT — no `AND`s.
|
||||||
|
let suffix = sql
|
||||||
|
.trim_start_matches(
|
||||||
|
"SELECT id, data, created_at, updated_at FROM docs WHERE app_id = $1 AND collection = $2",
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
assert!(
|
||||||
|
suffix.starts_with("ORDER BY"),
|
||||||
|
"expected ORDER BY immediately after base WHERE; got: {suffix}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eq_with_null_emits_is_null() {
|
||||||
|
let sql = sql_for(json!({ "x": null }));
|
||||||
|
assert!(sql.contains("IS NULL"), "sql: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ne_with_null_emits_is_not_null() {
|
||||||
|
let sql = sql_for(json!({ "x": { "$ne": null } }));
|
||||||
|
assert!(sql.contains("IS NOT NULL"), "sql: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ne_with_value_uses_is_distinct_from() {
|
||||||
|
// IS DISTINCT FROM, NOT <> — see ComparisonOp::Ne comment.
|
||||||
|
let sql = sql_for(json!({ "x": { "$ne": "v" } }));
|
||||||
|
assert!(sql.contains("IS DISTINCT FROM"), "sql: {sql}");
|
||||||
|
assert!(!sql.contains(" <> "), "sql: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_emits_any_array() {
|
||||||
|
let sql = sql_for(json!({ "x": { "$in": ["a", "b"] } }));
|
||||||
|
assert!(sql.contains("= ANY"), "sql: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_appends_tiebreaker_id_asc() {
|
||||||
|
let sql = sql_for(json!({ "$sort": { "created_at": -1 } }));
|
||||||
|
assert!(sql.contains("DESC, id ASC"), "sql: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonb_extract_path_used_for_field_access() {
|
||||||
|
let sql = sql_for(json!({ "user.email": "a@b" }));
|
||||||
|
assert!(sql.contains("jsonb_extract_path_text(data"), "sql: {sql}");
|
||||||
|
}
|
||||||
|
}
|
||||||
889
crates/manager-core/src/docs_service.rs
Normal file
889
crates/manager-core/src/docs_service.rs
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
//! `DocsServiceImpl` — wires the `DocsRepo` underneath the
|
||||||
|
//! `picloud_shared::DocsService` trait that scripts see via the Rhai
|
||||||
|
//! bridge.
|
||||||
|
//!
|
||||||
|
//! Layers added here (vs the raw repo):
|
||||||
|
//!
|
||||||
|
//! 1. Empty-collection rejection at the SDK boundary
|
||||||
|
//! (`docs/sdk-shape.md`).
|
||||||
|
//! 2. `data` must be a JSON object for create + update. (The repo
|
||||||
|
//! accepts anything serde_json can serialise; the SDK contract
|
||||||
|
//! pins documents to map shape so dotted-path queries make sense.)
|
||||||
|
//! 3. **Script-as-gate authz**: when `cx.principal.is_some()` we run
|
||||||
|
//! `authz::require(...)`; when it's `None` (public unauthenticated
|
||||||
|
//! HTTP — the common case for public routes) we skip the check.
|
||||||
|
//! Cross-app isolation isn't affected — every query is keyed by
|
||||||
|
//! `cx.app_id`, never an argument.
|
||||||
|
//! 4. Query DSL parse — `find`/`find_one` parse the opaque filter
|
||||||
|
//! into `DocsFilter` before passing it down. Parse errors map to
|
||||||
|
//! `DocsError::InvalidFilter` / `UnsupportedOperator` with the
|
||||||
|
//! parser's message verbatim (script-visible).
|
||||||
|
//! 5. `ServiceEvent` emission after each mutation (`create` / `update`
|
||||||
|
//! / `delete`). The outbox emitter (when wired) turns these into
|
||||||
|
//! docs-trigger fan-out via `OutboxEventEmitter::emit_docs`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{
|
||||||
|
DocId, DocRow, DocsError, DocsListPage, DocsService, SdkCallCx, ServiceEvent,
|
||||||
|
ServiceEventEmitter,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::authz::{self, AuthzRepo, Capability};
|
||||||
|
use crate::docs_filter::{parse_filter, FilterParseError};
|
||||||
|
use crate::docs_repo::{DocsRepo, DocsRepoError};
|
||||||
|
|
||||||
|
pub struct DocsServiceImpl {
|
||||||
|
repo: Arc<dyn DocsRepo>,
|
||||||
|
authz: Arc<dyn AuthzRepo>,
|
||||||
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocsServiceImpl {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
repo: Arc<dyn DocsRepo>,
|
||||||
|
authz: Arc<dyn AuthzRepo>,
|
||||||
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
repo,
|
||||||
|
authz,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), DocsError> {
|
||||||
|
if let Some(ref principal) = cx.principal {
|
||||||
|
authz::require(&*self.authz, principal, Capability::AppDocsRead(cx.app_id))
|
||||||
|
.await
|
||||||
|
.map_err(|_| DocsError::Forbidden)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), DocsError> {
|
||||||
|
if let Some(ref principal) = cx.principal {
|
||||||
|
authz::require(&*self.authz, principal, Capability::AppDocsWrite(cx.app_id))
|
||||||
|
.await
|
||||||
|
.map_err(|_| DocsError::Forbidden)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_collection(collection: &str) -> Result<(), DocsError> {
|
||||||
|
if collection.is_empty() {
|
||||||
|
return Err(DocsError::InvalidCollection);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_data(data: &serde_json::Value) -> Result<(), DocsError> {
|
||||||
|
if !data.is_object() {
|
||||||
|
return Err(DocsError::InvalidData);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DocsRepoError> for DocsError {
|
||||||
|
fn from(e: DocsRepoError) -> Self {
|
||||||
|
Self::Backend(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FilterParseError> for DocsError {
|
||||||
|
fn from(e: FilterParseError) -> Self {
|
||||||
|
match e {
|
||||||
|
FilterParseError::InvalidFilter(s) => Self::InvalidFilter(s),
|
||||||
|
FilterParseError::UnsupportedOperator(s) => Self::UnsupportedOperator(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DocsService for DocsServiceImpl {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<DocId, DocsError> {
|
||||||
|
validate_collection(collection)?;
|
||||||
|
validate_data(&data)?;
|
||||||
|
self.check_write(cx).await?;
|
||||||
|
let row = self
|
||||||
|
.repo
|
||||||
|
.create(cx.app_id, collection, data.clone())
|
||||||
|
.await?;
|
||||||
|
// Best-effort emit — a failed emit logs but does not roll back
|
||||||
|
// the write (mirrors KV's pattern).
|
||||||
|
if let Err(e) = self
|
||||||
|
.events
|
||||||
|
.emit(
|
||||||
|
cx,
|
||||||
|
ServiceEvent {
|
||||||
|
source: "docs",
|
||||||
|
op: "create",
|
||||||
|
collection: Some(collection.to_string()),
|
||||||
|
key: Some(row.id.to_string()),
|
||||||
|
payload: Some(data),
|
||||||
|
old_payload: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, source = "docs", op = "create", "event emit failed");
|
||||||
|
}
|
||||||
|
Ok(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<DocRow>, DocsError> {
|
||||||
|
validate_collection(collection)?;
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
Ok(self.repo.get(cx.app_id, collection, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
filter: serde_json::Value,
|
||||||
|
) -> Result<Vec<DocRow>, DocsError> {
|
||||||
|
validate_collection(collection)?;
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
let parsed = parse_filter(&filter)?;
|
||||||
|
Ok(self.repo.find(cx.app_id, collection, &parsed).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_one(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
filter: serde_json::Value,
|
||||||
|
) -> Result<Option<DocRow>, DocsError> {
|
||||||
|
validate_collection(collection)?;
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
let mut parsed = parse_filter(&filter)?;
|
||||||
|
// Inject the implicit `LIMIT 1` for find_one — explicit
|
||||||
|
// caller-supplied `$limit` wins.
|
||||||
|
if parsed.limit.is_none() {
|
||||||
|
parsed.limit = Some(1);
|
||||||
|
}
|
||||||
|
let rows = self.repo.find(cx.app_id, collection, &parsed).await?;
|
||||||
|
Ok(rows.into_iter().next())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<(), DocsError> {
|
||||||
|
validate_collection(collection)?;
|
||||||
|
validate_data(&data)?;
|
||||||
|
self.check_write(cx).await?;
|
||||||
|
let previous = self
|
||||||
|
.repo
|
||||||
|
.update(cx.app_id, collection, id, data.clone())
|
||||||
|
.await?;
|
||||||
|
match previous {
|
||||||
|
Some(prev) => {
|
||||||
|
if let Err(e) = self
|
||||||
|
.events
|
||||||
|
.emit(
|
||||||
|
cx,
|
||||||
|
ServiceEvent {
|
||||||
|
source: "docs",
|
||||||
|
op: "update",
|
||||||
|
collection: Some(collection.to_string()),
|
||||||
|
key: Some(id.to_string()),
|
||||||
|
payload: Some(data),
|
||||||
|
old_payload: Some(prev),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, source = "docs", op = "update", "event emit failed");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(DocsError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError> {
|
||||||
|
validate_collection(collection)?;
|
||||||
|
self.check_write(cx).await?;
|
||||||
|
let previous = self.repo.delete(cx.app_id, collection, id).await?;
|
||||||
|
let was_present = previous.is_some();
|
||||||
|
if let Some(prev) = previous {
|
||||||
|
if let Err(e) = self
|
||||||
|
.events
|
||||||
|
.emit(
|
||||||
|
cx,
|
||||||
|
ServiceEvent {
|
||||||
|
source: "docs",
|
||||||
|
op: "delete",
|
||||||
|
collection: Some(collection.to_string()),
|
||||||
|
key: Some(id.to_string()),
|
||||||
|
payload: None,
|
||||||
|
old_payload: Some(prev),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, source = "docs", op = "delete", "event emit failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(was_present)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<DocsListPage, DocsError> {
|
||||||
|
validate_collection(collection)?;
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests — in-memory DocsRepo so unit tests don't need Postgres.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
|
use crate::docs_filter::DocsFilter;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_shared::{
|
||||||
|
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal,
|
||||||
|
RequestId, UserId,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// In-memory backing: BTreeMap keyed by `(app_id, collection, id)`
|
||||||
|
/// so iteration is naturally ordered for stable cursor pagination
|
||||||
|
/// (matches the Postgres `ORDER BY id ASC`).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryDocsRepo {
|
||||||
|
data: Mutex<BTreeMap<(AppId, String, DocId), DocRow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DocsRepo for InMemoryDocsRepo {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<DocRow, DocsRepoError> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let row = DocRow {
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
self.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert((app_id, collection.to_string(), id), row.clone());
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<DocRow>, DocsRepoError> {
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(app_id, collection.to_string(), id))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
filter: &DocsFilter,
|
||||||
|
) -> Result<Vec<DocRow>, DocsRepoError> {
|
||||||
|
let map = self.data.lock().await;
|
||||||
|
let mut out: Vec<DocRow> = map
|
||||||
|
.iter()
|
||||||
|
.filter(|((a, c, _), _)| *a == app_id && c == collection)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.filter(|row| in_memory_matches(row, filter))
|
||||||
|
.collect();
|
||||||
|
if let Some(sort) = &filter.sort {
|
||||||
|
let path = sort.path.segments().to_vec();
|
||||||
|
let dir = sort.direction;
|
||||||
|
out.sort_by(|a, b| {
|
||||||
|
let av = extract_path_str(&a.data, &path);
|
||||||
|
let bv = extract_path_str(&b.data, &path);
|
||||||
|
let ord = av.cmp(&bv);
|
||||||
|
match dir {
|
||||||
|
crate::docs_filter::SortDir::Asc => ord,
|
||||||
|
crate::docs_filter::SortDir::Desc => ord.reverse(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.sort_by_key(|d| d.id);
|
||||||
|
}
|
||||||
|
if let Some(limit) = filter.limit {
|
||||||
|
out.truncate(limit as usize);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<Option<serde_json::Value>, DocsRepoError> {
|
||||||
|
let mut map = self.data.lock().await;
|
||||||
|
let key = (app_id, collection.to_string(), id);
|
||||||
|
let Some(existing) = map.get_mut(&key) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let prev = std::mem::replace(&mut existing.data, data);
|
||||||
|
existing.updated_at = Utc::now();
|
||||||
|
Ok(Some(prev))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<serde_json::Value>, DocsRepoError> {
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&(app_id, collection.to_string(), id))
|
||||||
|
.map(|row| row.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<DocsListPage, DocsRepoError> {
|
||||||
|
let map = self.data.lock().await;
|
||||||
|
let last_id = cursor
|
||||||
|
.map(|c| Uuid::parse_str(c).map_err(|_| DocsRepoError::InvalidCursor))
|
||||||
|
.transpose()?;
|
||||||
|
let mut docs: Vec<DocRow> = map
|
||||||
|
.iter()
|
||||||
|
.filter(|((a, c, _), _)| *a == app_id && c == collection)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.filter(|d| last_id.is_none_or(|lid| d.id > lid))
|
||||||
|
.collect();
|
||||||
|
docs.sort_by_key(|d| d.id);
|
||||||
|
let take = if limit == 0 {
|
||||||
|
usize::MAX
|
||||||
|
} else {
|
||||||
|
limit as usize
|
||||||
|
};
|
||||||
|
let next_cursor = if docs.len() > take {
|
||||||
|
docs.truncate(take);
|
||||||
|
docs.last().map(|d| d.id.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(DocsListPage { docs, next_cursor })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort in-memory filter eval mirroring the Postgres
|
||||||
|
/// semantics: extract each field path as a text-form string, then
|
||||||
|
/// apply the operator. Good enough for the unit tests; production
|
||||||
|
/// always goes through the Postgres impl.
|
||||||
|
fn in_memory_matches(row: &DocRow, filter: &DocsFilter) -> bool {
|
||||||
|
for cond in &filter.conditions {
|
||||||
|
let actual = extract_path_str(&row.data, cond.path.segments());
|
||||||
|
if !cond_matches(actual.as_ref(), cond) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cond_matches(actual: Option<&String>, cond: &crate::docs_filter::FieldCondition) -> bool {
|
||||||
|
use crate::docs_filter::ComparisonOp::*;
|
||||||
|
let actual: Option<&str> = actual.map(String::as_str);
|
||||||
|
let want = json_text(&cond.value);
|
||||||
|
let want_ref: Option<&str> = want.as_deref();
|
||||||
|
match cond.op {
|
||||||
|
Eq => actual == want_ref,
|
||||||
|
Ne => actual != want_ref,
|
||||||
|
Gt => actual.zip(want_ref).is_some_and(|(a, b)| a > b),
|
||||||
|
Gte => actual.zip(want_ref).is_some_and(|(a, b)| a >= b),
|
||||||
|
Lt => actual.zip(want_ref).is_some_and(|(a, b)| a < b),
|
||||||
|
Lte => actual.zip(want_ref).is_some_and(|(a, b)| a <= b),
|
||||||
|
In => {
|
||||||
|
let Some(arr) = cond.value.as_array() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
arr.iter().any(|v| actual == json_text(v).as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_path_str(value: &serde_json::Value, segments: &[String]) -> Option<String> {
|
||||||
|
let mut cur = value;
|
||||||
|
for seg in segments {
|
||||||
|
cur = cur.as_object()?.get(seg)?;
|
||||||
|
}
|
||||||
|
json_text(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_text(v: &serde_json::Value) -> Option<String> {
|
||||||
|
match v {
|
||||||
|
serde_json::Value::Null => None,
|
||||||
|
serde_json::Value::String(s) => Some(s.clone()),
|
||||||
|
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||||
|
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||||
|
serde_json::Value::Array(_) | serde_json::Value::Object(_) => Some(v.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DenyingAuthzRepo;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for DenyingAuthzRepo {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
_user_id: UserId,
|
||||||
|
_app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AllowingAuthzRepo;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for AllowingAuthzRepo {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
_user_id: UserId,
|
||||||
|
_app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(Some(AppRole::Editor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||||
|
SdkCallCx {
|
||||||
|
app_id,
|
||||||
|
principal: None,
|
||||||
|
execution_id: ExecutionId::new(),
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: ExecutionId::new(),
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_cx(app_id: AppId) -> SdkCallCx {
|
||||||
|
SdkCallCx {
|
||||||
|
app_id,
|
||||||
|
principal: Some(Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}),
|
||||||
|
execution_id: ExecutionId::new(),
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: ExecutionId::new(),
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
|
||||||
|
SdkCallCx {
|
||||||
|
app_id,
|
||||||
|
principal: Some(Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Member,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}),
|
||||||
|
execution_id: ExecutionId::new(),
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: ExecutionId::new(),
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn svc() -> DocsServiceImpl {
|
||||||
|
DocsServiceImpl::new(
|
||||||
|
Arc::new(InMemoryDocsRepo::default()),
|
||||||
|
Arc::new(DenyingAuthzRepo),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn svc_allowing() -> DocsServiceImpl {
|
||||||
|
DocsServiceImpl::new(
|
||||||
|
Arc::new(InMemoryDocsRepo::default()),
|
||||||
|
Arc::new(AllowingAuthzRepo),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_then_get_round_trips() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let id = s
|
||||||
|
.create(&cx, "users", json!({ "name": "Alice" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let row = s.get(&cx, "users", id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(row.id, id);
|
||||||
|
assert_eq!(row.data, json!({ "name": "Alice" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_missing_returns_none() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let v = s.get(&cx, "users", Uuid::new_v4()).await.unwrap();
|
||||||
|
assert!(v.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_missing_returns_not_found() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let err = s
|
||||||
|
.update(&cx, "users", Uuid::new_v4(), json!({ "x": 1 }))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DocsError::NotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_missing_returns_false() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let was_present = s.delete(&cx, "users", Uuid::new_v4()).await.unwrap();
|
||||||
|
assert!(!was_present);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_present_returns_true() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||||
|
let was_present = s.delete(&cx, "users", id).await.unwrap();
|
||||||
|
assert!(was_present);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_present_succeeds() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||||
|
s.update(&cx, "users", id, json!({ "x": 2 })).await.unwrap();
|
||||||
|
let row = s.get(&cx, "users", id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(row.data, json!({ "x": 2 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_collection_rejected() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let err = s.create(&cx, "", json!({})).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DocsError::InvalidCollection));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_with_non_object_data_rejected() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let err = s.create(&cx, "users", json!(42)).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DocsError::InvalidData));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_with_non_object_data_rejected() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||||
|
let err = s
|
||||||
|
.update(&cx, "users", id, json!("not an object"))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DocsError::InvalidData));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load-bearing: a script with `cx.app_id = A` must NOT see
|
||||||
|
/// documents created under `cx.app_id = B`. Cross-app isolation
|
||||||
|
/// boundary; tested through both `get` and `find` because each
|
||||||
|
/// path could conceivably leak independently.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cross_app_isolation_via_cx_app_id() {
|
||||||
|
let s = svc();
|
||||||
|
let app_a = AppId::new();
|
||||||
|
let app_b = AppId::new();
|
||||||
|
let cx_a = anon_cx(app_a);
|
||||||
|
let cx_b = anon_cx(app_b);
|
||||||
|
|
||||||
|
let id_a = s
|
||||||
|
.create(&cx_a, "shared", json!({ "from": "a" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let id_b = s
|
||||||
|
.create(&cx_b, "shared", json!({ "from": "b" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(id_a, id_b);
|
||||||
|
|
||||||
|
// Each app sees only its own doc via get.
|
||||||
|
assert!(s.get(&cx_a, "shared", id_b).await.unwrap().is_none());
|
||||||
|
assert!(s.get(&cx_b, "shared", id_a).await.unwrap().is_none());
|
||||||
|
|
||||||
|
// And via find.
|
||||||
|
let from_a = s.find(&cx_a, "shared", json!({})).await.unwrap();
|
||||||
|
assert_eq!(from_a.len(), 1);
|
||||||
|
assert_eq!(from_a[0].id, id_a);
|
||||||
|
|
||||||
|
let from_b = s.find(&cx_b, "shared", json!({})).await.unwrap();
|
||||||
|
assert_eq!(from_b.len(), 1);
|
||||||
|
assert_eq!(from_b[0].id, id_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anonymous_cx_skips_authz() {
|
||||||
|
// Denying authz repo + anon cx (no principal) ⇒ writes still
|
||||||
|
// succeed under script-as-gate.
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||||
|
let _ = s.delete(&cx, "users", id).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authed_cx_with_no_role_is_forbidden_on_write() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = member_no_role_cx(AppId::new());
|
||||||
|
let err = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DocsError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authed_cx_with_no_role_is_forbidden_on_read() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = member_no_role_cx(AppId::new());
|
||||||
|
let err = s.get(&cx, "users", Uuid::new_v4()).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DocsError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_principal_can_write() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = owner_cx(AppId::new());
|
||||||
|
let _ = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editor_member_can_write_via_role() {
|
||||||
|
// AllowingAuthzRepo grants Editor — should be able to write
|
||||||
|
// (AppDocsWrite is in_editor in role_satisfies).
|
||||||
|
let s = svc_allowing();
|
||||||
|
let cx = member_no_role_cx(AppId::new());
|
||||||
|
let _ = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_with_equality_returns_matches() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.create(&cx, "users", json!({ "tier": "silver" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let golds = s
|
||||||
|
.find(&cx, "users", json!({ "tier": "gold" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(golds.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_one_returns_first_or_none() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hit = s
|
||||||
|
.find_one(&cx, "users", json!({ "tier": "gold" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(hit.is_some());
|
||||||
|
|
||||||
|
let miss = s
|
||||||
|
.find_one(&cx, "users", json!({ "tier": "platinum" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(miss.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_with_unsupported_operator_throws() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let err = s
|
||||||
|
.find(&cx, "users", json!({ "name": { "$regex": "^A" } }))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
match err {
|
||||||
|
DocsError::UnsupportedOperator(m) => {
|
||||||
|
assert!(m.contains("$regex"));
|
||||||
|
assert!(m.contains("v1.2"));
|
||||||
|
}
|
||||||
|
other => panic!("expected UnsupportedOperator, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_with_invalid_filter_throws() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let err = s
|
||||||
|
.find(&cx, "users", json!({ "a.b.c.d.e.f": "x" }))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DocsError::InvalidFilter(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_with_dollar_in_returns_subset() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.create(&cx, "users", json!({ "tier": "silver" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.create(&cx, "users", json!({ "tier": "platinum" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hits = s
|
||||||
|
.find(
|
||||||
|
&cx,
|
||||||
|
"users",
|
||||||
|
json!({ "tier": { "$in": ["gold", "platinum"] } }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hits.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_one_explicit_limit_is_honoured() {
|
||||||
|
// The service injects limit=1 ONLY when caller didn't set
|
||||||
|
// $limit. An explicit `$limit: 5` survives — and find_one
|
||||||
|
// still returns the first.
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
for _ in 0..3 {
|
||||||
|
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let hit = s
|
||||||
|
.find_one(&cx, "users", json!({ "tier": "gold", "$limit": 5 }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(hit.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_cursor_pagination() {
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for _ in 0..5 {
|
||||||
|
ids.push(s.create(&cx, "users", json!({})).await.unwrap());
|
||||||
|
}
|
||||||
|
ids.sort();
|
||||||
|
|
||||||
|
let p1 = s.list(&cx, "users", None, 2).await.unwrap();
|
||||||
|
assert_eq!(p1.docs.len(), 2);
|
||||||
|
assert!(p1.next_cursor.is_some());
|
||||||
|
|
||||||
|
let p2 = s
|
||||||
|
.list(&cx, "users", p1.next_cursor.as_deref(), 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p2.docs.len(), 2);
|
||||||
|
|
||||||
|
let p3 = s
|
||||||
|
.list(&cx, "users", p2.next_cursor.as_deref(), 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(p3.docs.len(), 1);
|
||||||
|
assert!(p3.next_cursor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn noop_emitter_does_not_block_mutations() {
|
||||||
|
// Pins v1.1.0 contract: services hold an Arc<dyn ServiceEventEmitter>
|
||||||
|
// and call emit().await unconditionally. The noop drops it.
|
||||||
|
let s = svc();
|
||||||
|
let cx = anon_cx(AppId::new());
|
||||||
|
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||||
|
s.update(&cx, "users", id, json!({ "x": 2 })).await.unwrap();
|
||||||
|
let _ = s.delete(&cx, "users", id).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ pub mod dead_letter_repo;
|
|||||||
pub mod dead_letter_service;
|
pub mod dead_letter_service;
|
||||||
pub mod dead_letters_api;
|
pub mod dead_letters_api;
|
||||||
pub mod dispatcher;
|
pub mod dispatcher;
|
||||||
|
pub mod docs_filter;
|
||||||
|
pub mod docs_repo;
|
||||||
|
pub mod docs_service;
|
||||||
pub mod gc;
|
pub mod gc;
|
||||||
pub mod kv_repo;
|
pub mod kv_repo;
|
||||||
pub mod kv_service;
|
pub mod kv_service;
|
||||||
@@ -86,6 +89,8 @@ pub use dead_letter_repo::{
|
|||||||
pub use dead_letter_service::PostgresDeadLetterService;
|
pub use dead_letter_service::PostgresDeadLetterService;
|
||||||
pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState};
|
pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState};
|
||||||
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
|
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
|
||||||
|
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
|
||||||
|
pub use docs_service::DocsServiceImpl;
|
||||||
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
|
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
|
||||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||||
pub use kv_service::KvServiceImpl;
|
pub use kv_service::KvServiceImpl;
|
||||||
@@ -104,8 +109,8 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
|||||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||||
pub use trigger_config::{BackoffShape, TriggerConfig};
|
pub use trigger_config::{BackoffShape, TriggerConfig};
|
||||||
pub use trigger_repo::{
|
pub use trigger_repo::{
|
||||||
collection_matches, CreateDeadLetterTrigger, CreateKvTrigger, DeadLetterTriggerMatch,
|
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger,
|
||||||
KvTriggerMatch, PostgresTriggerRepo, Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind,
|
DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, PostgresTriggerRepo, Trigger,
|
||||||
TriggerRepo, TriggerRepoError,
|
TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
|
||||||
};
|
};
|
||||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent,
|
DocsEventOp, EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
||||||
@@ -42,6 +42,7 @@ impl ServiceEventEmitter for OutboxEventEmitter {
|
|||||||
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
|
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
|
||||||
match event.source {
|
match event.source {
|
||||||
"kv" => self.emit_kv(cx, event).await,
|
"kv" => self.emit_kv(cx, event).await,
|
||||||
|
"docs" => self.emit_docs(cx, event).await,
|
||||||
// Future sources land here. For now, silently drop — the
|
// Future sources land here. For now, silently drop — the
|
||||||
// SDK calls `events.emit(...)` unconditionally for forward
|
// SDK calls `events.emit(...)` unconditionally for forward
|
||||||
// compat, so swallowing without an error is correct.
|
// compat, so swallowing without an error is correct.
|
||||||
@@ -100,4 +101,57 @@ impl OutboxEventEmitter {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v1.1.2. Mirrors `emit_kv` — fan out a docs mutation across
|
||||||
|
/// matching docs triggers + write one outbox row each. The
|
||||||
|
/// `prev_data` change-data-capture surface is preserved from the
|
||||||
|
/// `ServiceEvent.old_payload` field (set by `DocsServiceImpl` on
|
||||||
|
/// update and delete; `None` for create).
|
||||||
|
async fn emit_docs(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
|
||||||
|
let Some(op) = DocsEventOp::from_wire(event.op) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(collection) = event.collection.clone() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let id = event.key.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
let matches = self
|
||||||
|
.triggers
|
||||||
|
.list_matching_docs(cx.app_id, &collection, op)
|
||||||
|
.await
|
||||||
|
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
|
||||||
|
|
||||||
|
if matches.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let trigger_event = TriggerEvent::Docs {
|
||||||
|
op,
|
||||||
|
collection,
|
||||||
|
id,
|
||||||
|
data: event.payload.clone(),
|
||||||
|
prev_data: event.old_payload.clone(),
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_value(&trigger_event)
|
||||||
|
.map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?;
|
||||||
|
|
||||||
|
for m in matches {
|
||||||
|
self.outbox
|
||||||
|
.insert(NewOutboxRow {
|
||||||
|
app_id: cx.app_id,
|
||||||
|
source_kind: OutboxSourceKind::Docs,
|
||||||
|
trigger_id: Some(m.trigger_id),
|
||||||
|
script_id: Some(m.script_id),
|
||||||
|
reply_to: None,
|
||||||
|
payload: payload.clone(),
|
||||||
|
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||||
|
trigger_depth: cx.trigger_depth.saturating_add(1),
|
||||||
|
root_execution_id: Some(cx.root_execution_id),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub enum OutboxRepoError {
|
|||||||
pub enum OutboxSourceKind {
|
pub enum OutboxSourceKind {
|
||||||
Http,
|
Http,
|
||||||
Kv,
|
Kv,
|
||||||
|
/// v1.1.2.
|
||||||
|
Docs,
|
||||||
DeadLetter,
|
DeadLetter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ impl OutboxSourceKind {
|
|||||||
match self {
|
match self {
|
||||||
Self::Http => "http",
|
Self::Http => "http",
|
||||||
Self::Kv => "kv",
|
Self::Kv => "kv",
|
||||||
|
Self::Docs => "docs",
|
||||||
Self::DeadLetter => "dead_letter",
|
Self::DeadLetter => "dead_letter",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +43,7 @@ impl OutboxSourceKind {
|
|||||||
match s {
|
match s {
|
||||||
"http" => Some(Self::Http),
|
"http" => Some(Self::Http),
|
||||||
"kv" => Some(Self::Kv),
|
"kv" => Some(Self::Kv),
|
||||||
|
"docs" => Some(Self::Docs),
|
||||||
"dead_letter" => Some(Self::DeadLetter),
|
"dead_letter" => Some(Self::DeadLetter),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{AdminUserId, AppId, KvEventOp, ScriptId, TriggerId};
|
use picloud_shared::{AdminUserId, AppId, DocsEventOp, KvEventOp, ScriptId, TriggerId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -47,6 +47,7 @@ pub struct Trigger {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TriggerKind {
|
pub enum TriggerKind {
|
||||||
Kv,
|
Kv,
|
||||||
|
Docs,
|
||||||
DeadLetter,
|
DeadLetter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ impl TriggerKind {
|
|||||||
pub const fn as_str(self) -> &'static str {
|
pub const fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Kv => "kv",
|
Self::Kv => "kv",
|
||||||
|
Self::Docs => "docs",
|
||||||
Self::DeadLetter => "dead_letter",
|
Self::DeadLetter => "dead_letter",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,7 @@ impl TriggerKind {
|
|||||||
pub fn from_wire(s: &str) -> Option<Self> {
|
pub fn from_wire(s: &str) -> Option<Self> {
|
||||||
match s {
|
match s {
|
||||||
"kv" => Some(Self::Kv),
|
"kv" => Some(Self::Kv),
|
||||||
|
"docs" => Some(Self::Docs),
|
||||||
"dead_letter" => Some(Self::DeadLetter),
|
"dead_letter" => Some(Self::DeadLetter),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -93,6 +96,10 @@ pub enum TriggerDetails {
|
|||||||
collection_glob: String,
|
collection_glob: String,
|
||||||
ops: Vec<KvEventOp>,
|
ops: Vec<KvEventOp>,
|
||||||
},
|
},
|
||||||
|
Docs {
|
||||||
|
collection_glob: String,
|
||||||
|
ops: Vec<DocsEventOp>,
|
||||||
|
},
|
||||||
DeadLetter {
|
DeadLetter {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
source_filter: Option<String>,
|
source_filter: Option<String>,
|
||||||
@@ -118,6 +125,20 @@ pub struct CreateKvTrigger {
|
|||||||
pub registered_by_principal: AdminUserId,
|
pub registered_by_principal: AdminUserId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create payload for a docs trigger (v1.1.2). Same shape as KV with
|
||||||
|
/// `DocsEventOp` ops instead of `KvEventOp`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CreateDocsTrigger {
|
||||||
|
pub script_id: ScriptId,
|
||||||
|
pub collection_glob: String,
|
||||||
|
pub ops: Vec<DocsEventOp>,
|
||||||
|
pub dispatch_mode: TriggerDispatchMode,
|
||||||
|
pub retry_max_attempts: u32,
|
||||||
|
pub retry_backoff: BackoffShape,
|
||||||
|
pub retry_base_ms: u32,
|
||||||
|
pub registered_by_principal: AdminUserId,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CreateDeadLetterTrigger {
|
pub struct CreateDeadLetterTrigger {
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
@@ -141,6 +162,19 @@ pub struct KvTriggerMatch {
|
|||||||
pub registered_by_principal: AdminUserId,
|
pub registered_by_principal: AdminUserId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One match for the dispatcher's docs trigger fan-out lookup (v1.1.2).
|
||||||
|
/// Same shape as `KvTriggerMatch`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DocsTriggerMatch {
|
||||||
|
pub trigger_id: TriggerId,
|
||||||
|
pub script_id: ScriptId,
|
||||||
|
pub dispatch_mode: TriggerDispatchMode,
|
||||||
|
pub retry_max_attempts: u32,
|
||||||
|
pub retry_backoff: BackoffShape,
|
||||||
|
pub retry_base_ms: u32,
|
||||||
|
pub registered_by_principal: AdminUserId,
|
||||||
|
}
|
||||||
|
|
||||||
/// One match for the dispatcher's "which dead-letter triggers fire
|
/// One match for the dispatcher's "which dead-letter triggers fire
|
||||||
/// on this dead-letter row" lookup.
|
/// on this dead-letter row" lookup.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -159,6 +193,13 @@ pub trait TriggerRepo: Send + Sync {
|
|||||||
req: CreateKvTrigger,
|
req: CreateKvTrigger,
|
||||||
) -> Result<Trigger, TriggerRepoError>;
|
) -> Result<Trigger, TriggerRepoError>;
|
||||||
|
|
||||||
|
/// v1.1.2.
|
||||||
|
async fn create_docs_trigger(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
req: CreateDocsTrigger,
|
||||||
|
) -> Result<Trigger, TriggerRepoError>;
|
||||||
|
|
||||||
async fn create_dead_letter_trigger(
|
async fn create_dead_letter_trigger(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
@@ -182,6 +223,16 @@ pub trait TriggerRepo: Send + Sync {
|
|||||||
op: KvEventOp,
|
op: KvEventOp,
|
||||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError>;
|
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError>;
|
||||||
|
|
||||||
|
/// Dispatcher hot path for docs fan-out (v1.1.2). Mirrors the KV
|
||||||
|
/// fan-out logic: pull every enabled docs trigger, filter glob +
|
||||||
|
/// ops in Rust (empty ops array means "any op").
|
||||||
|
async fn list_matching_docs(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
op: DocsEventOp,
|
||||||
|
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError>;
|
||||||
|
|
||||||
/// Dispatcher hot path for dead-letter fan-out. Filters: source
|
/// Dispatcher hot path for dead-letter fan-out. Filters: source
|
||||||
/// (or any-source), originating trigger_id (or any), originating
|
/// (or any-source), originating trigger_id (or any), originating
|
||||||
/// script_id (or any). Each filter is "match OR is_null".
|
/// script_id (or any). Each filter is "match OR is_null".
|
||||||
@@ -276,6 +327,71 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_docs_trigger(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
req: CreateDocsTrigger,
|
||||||
|
) -> Result<Trigger, TriggerRepoError> {
|
||||||
|
if req.collection_glob.is_empty() {
|
||||||
|
return Err(TriggerRepoError::Invalid(
|
||||||
|
"collection_glob must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let parent: TriggerRow = sqlx::query_as(
|
||||||
|
"INSERT INTO triggers ( \
|
||||||
|
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||||
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
|
registered_by_principal \
|
||||||
|
) VALUES ($1, $2, 'docs', TRUE, $3, $4, $5, $6, $7) \
|
||||||
|
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||||
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
|
registered_by_principal, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(req.script_id.into_inner())
|
||||||
|
.bind(req.dispatch_mode.as_str())
|
||||||
|
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
|
||||||
|
.bind(req.retry_backoff.as_str())
|
||||||
|
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
|
||||||
|
.bind(req.registered_by_principal.into_inner())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO docs_trigger_details (trigger_id, collection_glob, ops) \
|
||||||
|
VALUES ($1, $2, $3)",
|
||||||
|
)
|
||||||
|
.bind(parent.id)
|
||||||
|
.bind(&req.collection_glob)
|
||||||
|
.bind(&ops_str)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(Trigger {
|
||||||
|
id: parent.id.into(),
|
||||||
|
app_id: parent.app_id.into(),
|
||||||
|
script_id: parent.script_id.into(),
|
||||||
|
kind: TriggerKind::Docs,
|
||||||
|
enabled: parent.enabled,
|
||||||
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
|
.unwrap_or(BackoffShape::Exponential),
|
||||||
|
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
|
||||||
|
registered_by_principal: parent.registered_by_principal.into(),
|
||||||
|
created_at: parent.created_at,
|
||||||
|
updated_at: parent.updated_at,
|
||||||
|
details: TriggerDetails::Docs {
|
||||||
|
collection_glob: req.collection_glob,
|
||||||
|
ops: req.ops,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_dead_letter_trigger(
|
async fn create_dead_letter_trigger(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
@@ -427,6 +543,54 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_matching_docs(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
collection: &str,
|
||||||
|
op: DocsEventOp,
|
||||||
|
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
|
||||||
|
// Mirrors list_matching_kv: pull every enabled docs trigger,
|
||||||
|
// filter glob + ops in Rust. **Critical**: do NOT push the
|
||||||
|
// ops check into SQL (`WHERE $op = ANY(ops)`) — that would
|
||||||
|
// exclude rows with `ops = '{}'` from the results, breaking
|
||||||
|
// the empty-array-means-any-op semantic.
|
||||||
|
let rows: Vec<KvMatchRow> = sqlx::query_as(
|
||||||
|
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||||
|
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||||
|
t.registered_by_principal, \
|
||||||
|
d.collection_glob, d.ops \
|
||||||
|
FROM triggers t \
|
||||||
|
JOIN docs_trigger_details d ON d.trigger_id = t.id \
|
||||||
|
WHERE t.app_id = $1 AND t.kind = 'docs' AND t.enabled = TRUE",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let op_str = op.as_str();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
if !collection_matches(&r.collection_glob, collection) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let any_op = r.ops.is_empty();
|
||||||
|
if !any_op && !r.ops.iter().any(|o| o == op_str) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(DocsTriggerMatch {
|
||||||
|
trigger_id: r.id.into(),
|
||||||
|
script_id: r.script_id.into(),
|
||||||
|
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||||
|
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
|
||||||
|
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
|
||||||
|
.unwrap_or(BackoffShape::Exponential),
|
||||||
|
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
|
||||||
|
registered_by_principal: r.registered_by_principal.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_matching_dead_letter(
|
async fn list_matching_dead_letter(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
@@ -486,6 +650,23 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
|||||||
ops,
|
ops,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TriggerKind::Docs => {
|
||||||
|
let row: KvDetailRow = sqlx::query_as(
|
||||||
|
"SELECT collection_glob, ops FROM docs_trigger_details WHERE trigger_id = $1",
|
||||||
|
)
|
||||||
|
.bind(parent.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let ops = row
|
||||||
|
.ops
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| DocsEventOp::from_wire(s))
|
||||||
|
.collect();
|
||||||
|
TriggerDetails::Docs {
|
||||||
|
collection_glob: row.collection_glob,
|
||||||
|
ops,
|
||||||
|
}
|
||||||
|
}
|
||||||
TriggerKind::DeadLetter => {
|
TriggerKind::DeadLetter => {
|
||||||
let row: DlDetailRow = sqlx::query_as(
|
let row: DlDetailRow = sqlx::query_as(
|
||||||
"SELECT source_filter, trigger_id_filter, script_id_filter \
|
"SELECT source_filter, trigger_id_filter, script_id_filter \
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
use picloud_shared::{AppId, KvEventOp, Principal, ScriptId, TriggerId};
|
use picloud_shared::{AppId, DocsEventOp, KvEventOp, Principal, ScriptId, TriggerId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -24,8 +24,8 @@ use crate::app_repo::AppRepository;
|
|||||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
use crate::trigger_config::{BackoffShape, TriggerConfig};
|
use crate::trigger_config::{BackoffShape, TriggerConfig};
|
||||||
use crate::trigger_repo::{
|
use crate::trigger_repo::{
|
||||||
CreateDeadLetterTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode, TriggerRepo,
|
CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode,
|
||||||
TriggerRepoError,
|
TriggerRepo, TriggerRepoError,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -46,6 +46,7 @@ pub fn triggers_router(state: TriggersState) -> Router {
|
|||||||
get(list_triggers).delete(noop_405),
|
get(list_triggers).delete(noop_405),
|
||||||
)
|
)
|
||||||
.route("/apps/{app_id}/triggers/kv", post(create_kv_trigger))
|
.route("/apps/{app_id}/triggers/kv", post(create_kv_trigger))
|
||||||
|
.route("/apps/{app_id}/triggers/docs", post(create_docs_trigger))
|
||||||
.route(
|
.route(
|
||||||
"/apps/{app_id}/triggers/dead_letter",
|
"/apps/{app_id}/triggers/dead_letter",
|
||||||
post(create_dl_trigger),
|
post(create_dl_trigger),
|
||||||
@@ -90,6 +91,25 @@ const fn default_dispatch() -> TriggerDispatchMode {
|
|||||||
TriggerDispatchMode::Async
|
TriggerDispatchMode::Async
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v1.1.2. Same shape as `CreateKvTriggerRequest`; `ops` uses
|
||||||
|
/// `DocsEventOp` (`create` / `update` / `delete`) instead of
|
||||||
|
/// `KvEventOp` (`insert` / `update` / `delete`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDocsTriggerRequest {
|
||||||
|
pub script_id: ScriptId,
|
||||||
|
pub collection_glob: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ops: Vec<DocsEventOp>,
|
||||||
|
#[serde(default = "default_dispatch")]
|
||||||
|
pub dispatch_mode: TriggerDispatchMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_max_attempts: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_backoff: Option<BackoffShape>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_base_ms: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateDeadLetterTriggerRequest {
|
pub struct CreateDeadLetterTriggerRequest {
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
@@ -162,6 +182,42 @@ async fn create_kv_trigger(
|
|||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_docs_trigger(
|
||||||
|
State(s): State<TriggersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(app_id): Path<AppId>,
|
||||||
|
Json(input): Json<CreateDocsTriggerRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
|
||||||
|
ensure_app_exists(&*s.apps, app_id).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageTriggers(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if input.collection_glob.trim().is_empty() {
|
||||||
|
return Err(TriggersApiError::Invalid(
|
||||||
|
"collection_glob must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = CreateDocsTrigger {
|
||||||
|
script_id: input.script_id,
|
||||||
|
collection_glob: input.collection_glob,
|
||||||
|
ops: input.ops,
|
||||||
|
dispatch_mode: input.dispatch_mode,
|
||||||
|
retry_max_attempts: input
|
||||||
|
.retry_max_attempts
|
||||||
|
.unwrap_or(s.config.retry_max_attempts),
|
||||||
|
retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
|
||||||
|
retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
|
||||||
|
registered_by_principal: principal.user_id,
|
||||||
|
};
|
||||||
|
let created = s.triggers.create_docs_trigger(app_id, req).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_dl_trigger(
|
async fn create_dl_trigger(
|
||||||
State(s): State<TriggersState>,
|
State(s): State<TriggersState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
@@ -317,12 +373,14 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::app_repo::{AppLookup, AppRepository};
|
use crate::app_repo::{AppLookup, AppRepository};
|
||||||
use crate::trigger_repo::{
|
use crate::trigger_repo::{
|
||||||
DeadLetterTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo,
|
DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails,
|
||||||
TriggerRepoError,
|
TriggerRepo, TriggerRepoError,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_shared::{AdminUserId, App, AppRole, KvEventOp, ScriptId, TriggerId, UserId};
|
use picloud_shared::{
|
||||||
|
AdminUserId, App, AppRole, DocsEventOp, KvEventOp, ScriptId, TriggerId, UserId,
|
||||||
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -361,6 +419,34 @@ mod tests {
|
|||||||
self.inner.lock().await.insert(id, trigger.clone());
|
self.inner.lock().await.insert(id, trigger.clone());
|
||||||
Ok(trigger)
|
Ok(trigger)
|
||||||
}
|
}
|
||||||
|
async fn create_docs_trigger(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
req: CreateDocsTrigger,
|
||||||
|
) -> Result<Trigger, TriggerRepoError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = TriggerId::new();
|
||||||
|
let trigger = Trigger {
|
||||||
|
id,
|
||||||
|
app_id,
|
||||||
|
script_id: req.script_id,
|
||||||
|
kind: crate::trigger_repo::TriggerKind::Docs,
|
||||||
|
enabled: true,
|
||||||
|
dispatch_mode: req.dispatch_mode,
|
||||||
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
|
retry_backoff: req.retry_backoff,
|
||||||
|
retry_base_ms: req.retry_base_ms,
|
||||||
|
registered_by_principal: req.registered_by_principal,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
details: TriggerDetails::Docs {
|
||||||
|
collection_glob: req.collection_glob,
|
||||||
|
ops: req.ops,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.inner.lock().await.insert(id, trigger.clone());
|
||||||
|
Ok(trigger)
|
||||||
|
}
|
||||||
async fn create_dead_letter_trigger(
|
async fn create_dead_letter_trigger(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
@@ -414,6 +500,14 @@ mod tests {
|
|||||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
async fn list_matching_docs(
|
||||||
|
&self,
|
||||||
|
_app_id: AppId,
|
||||||
|
_collection: &str,
|
||||||
|
_op: DocsEventOp,
|
||||||
|
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
async fn list_matching_dead_letter(
|
async fn list_matching_dead_letter(
|
||||||
&self,
|
&self,
|
||||||
_app_id: AppId,
|
_app_id: AppId,
|
||||||
@@ -672,6 +766,89 @@ mod tests {
|
|||||||
assert!(matches!(err, TriggersApiError::Invalid(_)));
|
assert!(matches!(err, TriggersApiError::Invalid(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn docs_trigger_create_succeeds() {
|
||||||
|
let app_id = AppId::new();
|
||||||
|
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
|
||||||
|
let (status, Json(trigger)) = create_docs_trigger(
|
||||||
|
State(state),
|
||||||
|
Extension(member_principal()),
|
||||||
|
Path(app_id),
|
||||||
|
Json(CreateDocsTriggerRequest {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
collection_glob: "users".into(),
|
||||||
|
ops: vec![DocsEventOp::Create, DocsEventOp::Update],
|
||||||
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
|
retry_max_attempts: None,
|
||||||
|
retry_backoff: None,
|
||||||
|
retry_base_ms: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
|
assert!(matches!(
|
||||||
|
trigger.kind,
|
||||||
|
crate::trigger_repo::TriggerKind::Docs
|
||||||
|
));
|
||||||
|
match trigger.details {
|
||||||
|
TriggerDetails::Docs {
|
||||||
|
collection_glob,
|
||||||
|
ops,
|
||||||
|
} => {
|
||||||
|
assert_eq!(collection_glob, "users");
|
||||||
|
assert_eq!(ops, vec![DocsEventOp::Create, DocsEventOp::Update]);
|
||||||
|
}
|
||||||
|
other => panic!("expected Docs details, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn docs_trigger_empty_glob_rejected() {
|
||||||
|
let app_id = AppId::new();
|
||||||
|
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
|
||||||
|
let res = create_docs_trigger(
|
||||||
|
State(state),
|
||||||
|
Extension(member_principal()),
|
||||||
|
Path(app_id),
|
||||||
|
Json(CreateDocsTriggerRequest {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
collection_glob: " ".into(),
|
||||||
|
ops: vec![],
|
||||||
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
|
retry_max_attempts: None,
|
||||||
|
retry_backoff: None,
|
||||||
|
retry_base_ms: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let err = res.expect_err("empty docs glob should reject");
|
||||||
|
assert!(matches!(err, TriggersApiError::Invalid(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn docs_trigger_member_without_role_is_forbidden() {
|
||||||
|
let app_id = AppId::new();
|
||||||
|
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
|
||||||
|
let res = create_docs_trigger(
|
||||||
|
State(state),
|
||||||
|
Extension(member_principal()),
|
||||||
|
Path(app_id),
|
||||||
|
Json(CreateDocsTriggerRequest {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
collection_glob: "users".into(),
|
||||||
|
ops: vec![],
|
||||||
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
|
retry_max_attempts: None,
|
||||||
|
retry_backoff: None,
|
||||||
|
retry_base_ms: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let err = res.expect_err("member without role should be forbidden");
|
||||||
|
assert!(matches!(err, TriggersApiError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_rejects_cross_app_trigger_id() {
|
async fn delete_rejects_cross_app_trigger_id() {
|
||||||
let app_a = AppId::new();
|
let app_a = AppId::new();
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ use picloud_manager_core::{
|
|||||||
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||||
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
||||||
AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher,
|
AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher,
|
||||||
KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
|
DocsServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
|
||||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||||
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||||
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresExecutionLogRepository,
|
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresRouteRepository,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
|
||||||
PostgresScriptRepository, PostgresTriggerRepo, PrincipalResolver, RepoResolver,
|
PostgresRouteRepository, PostgresScriptRepository, PostgresTriggerRepo, PrincipalResolver,
|
||||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, TriggerConfig, TriggerRepo,
|
RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository,
|
||||||
TriggersState,
|
TriggerConfig, TriggerRepo, TriggersState,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
@@ -31,8 +31,9 @@ use picloud_orchestrator_core::{
|
|||||||
LocalExecutorClient,
|
LocalExecutorClient,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
DeadLetterService, ExecutionLogSink, InboxResolver, KvService, OutboxWriter, ScriptValidator,
|
DeadLetterService, DocsService, ExecutionLogSink, InboxResolver, KvService, OutboxWriter,
|
||||||
ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
|
||||||
|
WIRE_VERSION,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -119,22 +120,29 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let abandoned_repo: Arc<dyn AbandonedRepo> = Arc::new(PostgresAbandonedRepo::new(pool.clone()));
|
let abandoned_repo: Arc<dyn AbandonedRepo> = Arc::new(PostgresAbandonedRepo::new(pool.clone()));
|
||||||
let trigger_config = TriggerConfig::from_env();
|
let trigger_config = TriggerConfig::from_env();
|
||||||
|
|
||||||
// SDK services bundle. v1.1.1 ships the KV store + the
|
// SDK services bundle. v1.1.1 added KV + dead-letter; v1.1.2 adds
|
||||||
// outbox-backed event emitter + the dead-letter service (replay /
|
// the docs store. All four bound services share the
|
||||||
// resolve).
|
// outbox-backed event emitter so KV and docs mutations both fan
|
||||||
let kv_repo = Arc::new(PostgresKvRepo::new(pool));
|
// out through the same dispatcher.
|
||||||
|
let kv_repo = Arc::new(PostgresKvRepo::new(pool.clone()));
|
||||||
|
let docs_repo = Arc::new(PostgresDocsRepo::new(pool));
|
||||||
let events: Arc<dyn ServiceEventEmitter> = Arc::new(OutboxEventEmitter::new(
|
let events: Arc<dyn ServiceEventEmitter> = Arc::new(OutboxEventEmitter::new(
|
||||||
trigger_repo.clone(),
|
trigger_repo.clone(),
|
||||||
outbox_repo.clone(),
|
outbox_repo.clone(),
|
||||||
));
|
));
|
||||||
let kv: Arc<dyn KvService> =
|
let kv: Arc<dyn KvService> =
|
||||||
Arc::new(KvServiceImpl::new(kv_repo, authz.clone(), events.clone()));
|
Arc::new(KvServiceImpl::new(kv_repo, authz.clone(), events.clone()));
|
||||||
|
let docs: Arc<dyn DocsService> = Arc::new(DocsServiceImpl::new(
|
||||||
|
docs_repo,
|
||||||
|
authz.clone(),
|
||||||
|
events.clone(),
|
||||||
|
));
|
||||||
let dl_service: Arc<dyn DeadLetterService> = Arc::new(PostgresDeadLetterService::new(
|
let dl_service: Arc<dyn DeadLetterService> = Arc::new(PostgresDeadLetterService::new(
|
||||||
dl_repo.clone(),
|
dl_repo.clone(),
|
||||||
outbox_repo.clone(),
|
outbox_repo.clone(),
|
||||||
authz.clone(),
|
authz.clone(),
|
||||||
));
|
));
|
||||||
let services = Services::new(kv, dl_service.clone(), events);
|
let services = Services::new(kv, docs, dl_service.clone(), events);
|
||||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
|
|||||||
259
crates/shared/src/docs.rs
Normal file
259
crates/shared/src/docs.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
//! `DocsService` — the v1.1.2 schemaless document store contract.
|
||||||
|
//!
|
||||||
|
//! Lives in `picloud-shared` (not `executor-core`) for the same reason
|
||||||
|
//! `KvService` does: the Rhai bridge, the manager-core Postgres impl,
|
||||||
|
//! and any future in-memory test impl all depend on the same trait
|
||||||
|
//! without dragging `executor-core` into `manager-core`'s dep graph.
|
||||||
|
//!
|
||||||
|
//! Implementations MUST derive every storage `app_id` from `cx.app_id`
|
||||||
|
//! — never from a script-passed argument. That is the cross-app
|
||||||
|
//! isolation boundary; see `docs/sdk-shape.md`.
|
||||||
|
//!
|
||||||
|
//! Filter shape (per `docs::find` / `find_one`) is an opaque
|
||||||
|
//! `serde_json::Value` at this layer; the manager-core implementation
|
||||||
|
//! parses it into a structured DSL with explicit operator allowlist
|
||||||
|
//! before touching SQL. Parser errors surface as
|
||||||
|
//! `DocsError::InvalidFilter` / `DocsError::UnsupportedOperator` so
|
||||||
|
//! scripts get a clear message naming the offending key.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::SdkCallCx;
|
||||||
|
|
||||||
|
/// Server-generated document identifier. Scripts see the `to_string()`
|
||||||
|
/// form as a Rhai string; the trait surface keeps the typed `Uuid` so
|
||||||
|
/// no implementation accidentally accepts a string-shaped path
|
||||||
|
/// parameter from a script.
|
||||||
|
pub type DocId = Uuid;
|
||||||
|
|
||||||
|
/// One document as returned by `get` / `find` / `find_one`. The
|
||||||
|
/// envelope shape (decision D from the v1.1.2 plan): explicit
|
||||||
|
/// `id`+`data`+timestamps so user fields and platform metadata can't
|
||||||
|
/// alias. Scripts read user fields via `doc.data.<field>`; timestamps
|
||||||
|
/// + id are direct children.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct DocRow {
|
||||||
|
pub id: DocId,
|
||||||
|
pub data: serde_json::Value,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of `list`. `next_cursor` is `Some` when more pages exist,
|
||||||
|
/// `None` when exhausted. Mirrors `KvListPage`'s shape; the cursor
|
||||||
|
/// encoding is implementation-defined (the Postgres impl base64-encodes
|
||||||
|
/// the last id).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DocsListPage {
|
||||||
|
pub docs: Vec<DocRow>,
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collection-scoped CRUD + cursor list + filter-based find.
|
||||||
|
///
|
||||||
|
/// Method shapes mirror `KvService`'s signature style (each takes
|
||||||
|
/// `&SdkCallCx` first non-self). The collection name is passed by
|
||||||
|
/// reference; the implementation rejects empty/whitespace-only
|
||||||
|
/// collections at the SDK boundary per `docs/sdk-shape.md`.
|
||||||
|
///
|
||||||
|
/// `find` and `find_one` take the filter as `serde_json::Value` — the
|
||||||
|
/// service implementation parses it into a structured AST. Keeping the
|
||||||
|
/// trait signature untyped here lets the bridge convert
|
||||||
|
/// `Rhai Map → serde_json::Value` and hand it off without dragging the
|
||||||
|
/// parser into the shared crate.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DocsService: Send + Sync {
|
||||||
|
/// Create a new document with a server-generated UUID. Returns the
|
||||||
|
/// new id so the script can read/update/delete it later. The
|
||||||
|
/// document `data` must be a JSON object.
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<DocId, DocsError>;
|
||||||
|
|
||||||
|
/// Fetch one document by id. Returns `None` for missing — the
|
||||||
|
/// bridge maps that to Rhai's `()`.
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
) -> Result<Option<DocRow>, DocsError>;
|
||||||
|
|
||||||
|
/// Filter-based query. Returns every matching document as a
|
||||||
|
/// `Vec<DocRow>` (empty when no matches). The filter is the
|
||||||
|
/// v1.1.2 query DSL shape — see `manager-core::docs_filter` for
|
||||||
|
/// the parser. Throws `InvalidFilter` / `UnsupportedOperator` on
|
||||||
|
/// parse errors.
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
filter: serde_json::Value,
|
||||||
|
) -> Result<Vec<DocRow>, DocsError>;
|
||||||
|
|
||||||
|
/// Single-result variant — equivalent to `find` with `$limit: 1`
|
||||||
|
/// then take-first. Returns `None` when no document matches.
|
||||||
|
async fn find_one(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
filter: serde_json::Value,
|
||||||
|
) -> Result<Option<DocRow>, DocsError>;
|
||||||
|
|
||||||
|
/// Full document replace. v1.1.2 has no partial-update DSL —
|
||||||
|
/// scripts that want partial update do `get + modify + update`.
|
||||||
|
/// Returns `DocsError::NotFound` if no such doc; otherwise emits
|
||||||
|
/// an `update` ServiceEvent with `prev_data` and `data`.
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
id: DocId,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<(), DocsError>;
|
||||||
|
|
||||||
|
/// Delete by id. Returns `bool was-present` (matches the `delete`
|
||||||
|
/// shape of every v1.1.x service). Emits a `delete` ServiceEvent
|
||||||
|
/// with `prev_data: Some(deleted_doc.data)` when the doc existed.
|
||||||
|
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError>;
|
||||||
|
|
||||||
|
/// Cursor-paginated listing of every doc in the collection,
|
||||||
|
/// ordered by `id ASC` for stable cursor encoding. `None` cursor
|
||||||
|
/// starts from the beginning. Implementations cap `limit` at a
|
||||||
|
/// reasonable ceiling internally.
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<DocsListPage, DocsError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub for tests that build a `Services` bundle without spinning up
|
||||||
|
/// Postgres. Every call returns `DocsError::Backend("...")` so
|
||||||
|
/// accidental docs use surfaces clearly. Mirrors `NoopKvService`.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopDocsService;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DocsService for NoopDocsService {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_collection: &str,
|
||||||
|
_data: serde_json::Value,
|
||||||
|
) -> Result<DocId, DocsError> {
|
||||||
|
Err(DocsError::Backend("docs is not wired in".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_collection: &str,
|
||||||
|
_id: DocId,
|
||||||
|
) -> Result<Option<DocRow>, DocsError> {
|
||||||
|
Err(DocsError::Backend("docs is not wired in".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_collection: &str,
|
||||||
|
_filter: serde_json::Value,
|
||||||
|
) -> Result<Vec<DocRow>, DocsError> {
|
||||||
|
Err(DocsError::Backend("docs is not wired in".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_one(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_collection: &str,
|
||||||
|
_filter: serde_json::Value,
|
||||||
|
) -> Result<Option<DocRow>, DocsError> {
|
||||||
|
Err(DocsError::Backend("docs is not wired in".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_collection: &str,
|
||||||
|
_id: DocId,
|
||||||
|
_data: serde_json::Value,
|
||||||
|
) -> Result<(), DocsError> {
|
||||||
|
Err(DocsError::Backend("docs is not wired in".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_collection: &str,
|
||||||
|
_id: DocId,
|
||||||
|
) -> Result<bool, DocsError> {
|
||||||
|
Err(DocsError::Backend("docs is not wired in".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_collection: &str,
|
||||||
|
_cursor: Option<&str>,
|
||||||
|
_limit: u32,
|
||||||
|
) -> Result<DocsListPage, DocsError> {
|
||||||
|
Err(DocsError::Backend("docs is not wired in".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure modes surfaced to the Rhai bridge. The bridge converts each
|
||||||
|
/// to a Rhai runtime error string; the discriminants exist so internal
|
||||||
|
/// callers (admin endpoints, tests) can react more precisely.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DocsError {
|
||||||
|
/// Empty collection name; rejected at the SDK boundary per
|
||||||
|
/// `docs/sdk-shape.md`.
|
||||||
|
#[error("collection name must not be empty")]
|
||||||
|
InvalidCollection,
|
||||||
|
|
||||||
|
/// `create`/`update` was handed a non-object JSON value (data must
|
||||||
|
/// be a JSON object so it can be navigated by field paths in
|
||||||
|
/// queries).
|
||||||
|
#[error("document data must be a JSON object")]
|
||||||
|
InvalidData,
|
||||||
|
|
||||||
|
/// Parser rejected the filter — bad path syntax, malformed
|
||||||
|
/// operator value, multi-field `$sort`, etc. The string is the
|
||||||
|
/// script-visible message; it becomes part of the SDK contract
|
||||||
|
/// once a script depends on it.
|
||||||
|
#[error("invalid filter: {0}")]
|
||||||
|
InvalidFilter(String),
|
||||||
|
|
||||||
|
/// Filter used an operator that's not in the v1.1.2 allowlist
|
||||||
|
/// (`$or`, `$regex`, `$exists`, …). String includes the offending
|
||||||
|
/// operator name + v1.2 pointer.
|
||||||
|
#[error("unsupported operator: {0}")]
|
||||||
|
UnsupportedOperator(String),
|
||||||
|
|
||||||
|
/// `update` / `delete` target id does not exist. (`delete` returns
|
||||||
|
/// `Ok(false)` for "missing"; this variant is for `update` and any
|
||||||
|
/// future delete-must-exist callers.)
|
||||||
|
#[error("document not found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
/// Caller principal lacked the required capability. Only raised
|
||||||
|
/// when `cx.principal.is_some()` — scripts running with
|
||||||
|
/// `principal: None` (public HTTP) operate under script-as-gate
|
||||||
|
/// semantics and skip the capability check.
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
/// Anything else — Postgres unavailable, serialization failure,
|
||||||
|
/// etc. The string is safe to surface to a script.
|
||||||
|
#[error("docs backend error: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
|
pub mod docs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod exec_summary;
|
pub mod exec_summary;
|
||||||
@@ -28,6 +29,7 @@ pub mod version;
|
|||||||
pub use app::{App, AppDomain, DomainShape};
|
pub use app::{App, AppDomain, DomainShape};
|
||||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||||
pub use dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService};
|
pub use dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService};
|
||||||
|
pub use docs::{DocId, DocRow, DocsError, DocsListPage, DocsService, NoopDocsService};
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
|
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
|
||||||
pub use exec_summary::ExecResponseSummary;
|
pub use exec_summary::ExecResponseSummary;
|
||||||
@@ -44,6 +46,6 @@ pub use sandbox::ScriptSandbox;
|
|||||||
pub use script::Script;
|
pub use script::Script;
|
||||||
pub use sdk_cx::SdkCallCx;
|
pub use sdk_cx::SdkCallCx;
|
||||||
pub use services::Services;
|
pub use services::Services;
|
||||||
pub use trigger_event::{DeadLetterEventDetail, KvEventOp, TriggerEvent};
|
pub use trigger_event::{DeadLetterEventDetail, DocsEventOp, KvEventOp, TriggerEvent};
|
||||||
pub use validator::{ScriptValidator, ValidationError};
|
pub use validator::{ScriptValidator, ValidationError};
|
||||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DeadLetterService, KvService, NoopDeadLetterService, NoopEventEmitter, NoopKvService,
|
DeadLetterService, DocsService, KvService, NoopDeadLetterService, NoopDocsService,
|
||||||
ServiceEventEmitter,
|
NoopEventEmitter, NoopKvService, ServiceEventEmitter,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||||
@@ -30,15 +30,20 @@ pub struct Services {
|
|||||||
/// in-memory in tests.
|
/// in-memory in tests.
|
||||||
pub kv: Arc<dyn KvService>,
|
pub kv: Arc<dyn KvService>,
|
||||||
|
|
||||||
|
/// Document store (v1.1.2). Backed by Postgres in the picloud
|
||||||
|
/// binary; in-memory in tests.
|
||||||
|
pub docs: Arc<dyn DocsService>,
|
||||||
|
|
||||||
/// Dead-letter management (v1.1.1). Scripts get
|
/// Dead-letter management (v1.1.1). Scripts get
|
||||||
/// `dead_letters::replay(id)` and `dead_letters::resolve(id, reason)`.
|
/// `dead_letters::replay(id)` and `dead_letters::resolve(id, reason)`.
|
||||||
pub dead_letters: Arc<dyn DeadLetterService>,
|
pub dead_letters: Arc<dyn DeadLetterService>,
|
||||||
|
|
||||||
/// Event emitter for the triggers outbox. Mutating service methods
|
/// Event emitter for the triggers outbox. Mutating service methods
|
||||||
/// (`KvService::set/delete`, future `docs::*`, `files::*`, etc.)
|
/// (`KvService::set/delete`, `DocsService::create/update/delete`,
|
||||||
/// call `events.emit(cx, event)` after the write succeeds. The
|
/// future `files::*`, etc.) call `events.emit(cx, event)` after
|
||||||
/// outbox-backed impl in `manager-core::outbox_event_emitter`
|
/// the write succeeds. The outbox-backed impl in
|
||||||
/// replaces v1.1.0's `NoopEventEmitter`.
|
/// `manager-core::outbox_event_emitter` replaces v1.1.0's
|
||||||
|
/// `NoopEventEmitter`.
|
||||||
pub events: Arc<dyn ServiceEventEmitter>,
|
pub events: Arc<dyn ServiceEventEmitter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +54,13 @@ impl Services {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
kv: Arc<dyn KvService>,
|
kv: Arc<dyn KvService>,
|
||||||
|
docs: Arc<dyn DocsService>,
|
||||||
dead_letters: Arc<dyn DeadLetterService>,
|
dead_letters: Arc<dyn DeadLetterService>,
|
||||||
events: Arc<dyn ServiceEventEmitter>,
|
events: Arc<dyn ServiceEventEmitter>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kv,
|
kv,
|
||||||
|
docs,
|
||||||
dead_letters,
|
dead_letters,
|
||||||
events,
|
events,
|
||||||
}
|
}
|
||||||
@@ -68,6 +75,7 @@ impl Services {
|
|||||||
pub fn with_noop_services() -> Self {
|
pub fn with_noop_services() -> Self {
|
||||||
Self::new(
|
Self::new(
|
||||||
Arc::new(NoopKvService),
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
Arc::new(NoopDeadLetterService),
|
Arc::new(NoopDeadLetterService),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,39 @@ impl KvEventOp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operations a docs trigger can fire on. v1.1.2. Stored as a
|
||||||
|
/// lowercase string in `docs_trigger_details.ops` (Postgres `text[]`).
|
||||||
|
/// Distinct from `KvEventOp` because docs has CRUD verbs (`create`)
|
||||||
|
/// instead of KV's set/upsert flavour (`insert`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DocsEventOp {
|
||||||
|
Create,
|
||||||
|
Update,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocsEventOp {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Create => "create",
|
||||||
|
Self::Update => "update",
|
||||||
|
Self::Delete => "delete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_wire(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"create" => Some(Self::Create),
|
||||||
|
"update" => Some(Self::Update),
|
||||||
|
"delete" => Some(Self::Delete),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Discriminated description of a triggering event. Lifted from the
|
/// Discriminated description of a triggering event. Lifted from the
|
||||||
/// outbox row's payload at dispatch time. Each variant carries the
|
/// outbox row's payload at dispatch time. Each variant carries the
|
||||||
/// fields the corresponding `ctx.event` shape exposes to the script.
|
/// fields the corresponding `ctx.event` shape exposes to the script.
|
||||||
@@ -61,6 +94,23 @@ pub enum TriggerEvent {
|
|||||||
value: Option<serde_json::Value>,
|
value: Option<serde_json::Value>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A docs create / update / delete fired this handler. v1.1.2.
|
||||||
|
/// `data` is the current document state (absent on delete);
|
||||||
|
/// `prev_data` is the prior state (absent on create). For update
|
||||||
|
/// and delete handlers, `prev_data` is the load-bearing
|
||||||
|
/// change-data-capture surface (the repo reads the old row in the
|
||||||
|
/// same statement as the write).
|
||||||
|
Docs {
|
||||||
|
op: DocsEventOp,
|
||||||
|
collection: String,
|
||||||
|
/// UUID as string — Rhai sees it as a string.
|
||||||
|
id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
data: Option<serde_json::Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
prev_data: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
|
||||||
/// A dead-letter row fired this handler. The original event is
|
/// A dead-letter row fired this handler. The original event is
|
||||||
/// nested verbatim plus the dead-letter metadata the design notes
|
/// nested verbatim plus the dead-letter metadata the design notes
|
||||||
/// §4 require.
|
/// §4 require.
|
||||||
@@ -84,6 +134,7 @@ impl TriggerEvent {
|
|||||||
pub const fn source(&self) -> &'static str {
|
pub const fn source(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Kv { .. } => "kv",
|
Self::Kv { .. } => "kv",
|
||||||
|
Self::Docs { .. } => "docs",
|
||||||
Self::DeadLetter { .. } => "dead_letter",
|
Self::DeadLetter { .. } => "dead_letter",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||||||
///
|
///
|
||||||
/// 1.2 additions (v1.1.1): `kv::collection(name).{get,set,has,delete,list}`,
|
/// 1.2 additions (v1.1.1): `kv::collection(name).{get,set,has,delete,list}`,
|
||||||
/// `dead_letters::{replay,resolve}`, `ctx.event` for triggered handlers.
|
/// `dead_letters::{replay,resolve}`, `ctx.event` for triggered handlers.
|
||||||
pub const SDK_VERSION: &str = "1.2";
|
///
|
||||||
|
/// 1.3 additions (v1.1.2):
|
||||||
|
/// `docs::collection(name).{create,get,find,find_one,update,delete,list}`
|
||||||
|
/// with the v1.1.2 query DSL subset; `ctx.event.docs` for docs-trigger
|
||||||
|
/// handlers (carries `prev_data` change-data-capture for update/delete).
|
||||||
|
pub const SDK_VERSION: &str = "1.3";
|
||||||
|
|
||||||
/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`.
|
/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`.
|
||||||
/// Bump (new integer + new URL prefix) when the request/response
|
/// Bump (new integer + new URL prefix) when the request/response
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user