From fe814ea6057fb7b1a005d2580a8bce497a2542b4 Mon Sep 17 00:00:00 2001 From: Jason Doyle <46789294+Jason-Doyle@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:59:40 -0700 Subject: [PATCH] Add bounded read bundles and covering indexes --- CHANGELOG.md | 17 + README.md | 9 +- deploy/cloudflare/wrangler.example.jsonc | 1 + docs/ARCHITECTURE.md | 38 +- docs/AUTHENTICATION.md | 5 + docs/AUTHORITY-DEPLOYMENT.md | 201 ++++++++ docs/BENCHMARKS.md | 7 + docs/DEPLOYMENT-AWS.md | 9 + docs/DEPLOYMENT-AZURE.md | 8 + docs/DEPLOYMENT-CLOUDFLARE.md | 30 +- docs/DIAGRAMS.md | 97 +++- docs/EVALUATION.md | 8 +- docs/EXAMPLES.md | 2 + docs/FAQ.md | 21 + docs/IMPLEMENTATION-PROMPTS.md | 32 +- docs/OPERATIONS.md | 4 +- docs/PROTOCOL.md | 50 +- docs/PUBLIC-API.md | 32 +- docs/QUERIES-INDEXES.md | 75 +++ docs/QUICKSTART.md | 68 ++- docs/README.md | 1 + docs/SECURITY.md | 13 + docs/STUDIO.md | 4 + docs/TRADEOFFS.md | 6 + docs/VERSIONING.md | 17 + package-lock.json | 4 +- package.json | 3 +- scripts/e2e-server.ts | 4 +- scripts/verify-package.mjs | 38 ++ site/src/data/docs.ts | 11 +- site/src/pages/index.astro | 13 +- site/src/pages/llms.txt.ts | 3 +- site/tests/site.spec.ts | 21 + src/browser/client.ts | 196 +++++++- src/browser/collection.ts | 137 ++++++ src/browser/connect.ts | 22 + src/browser/main.ts | 3 + src/browser/remote-reader.ts | 117 +++++ src/cloudflare-worker.ts | 94 +++- src/engines/content-trie.ts | 228 ++++++--- src/engines/immutable-snapshot.ts | 175 +++++-- src/query.ts | 40 ++ src/read-bundle.ts | 39 ++ src/secondary-index.ts | 250 +++++++++- src/server.ts | 92 +++- src/trie-protocol.ts | 6 + studio/src/main.ts | 4 +- templates/local-web/server.mjs | 2 + templates/local-web/src/main.ts | 45 +- tests/browser-client.test.ts | 95 ++++ tests/browser-connect.test.ts | 71 +++ tests/cloudflare-worker.test.ts | 18 + tests/collection.test.ts | 144 ++++++ tests/e2e/authenticated-store.spec.ts | 48 ++ tests/read-bundle.test.ts | 135 ++++++ tests/secondary-index.test.ts | 594 +++++++++++++++++++++++ 56 files changed, 3198 insertions(+), 209 deletions(-) create mode 100644 docs/AUTHORITY-DEPLOYMENT.md create mode 100644 src/read-bundle.ts create mode 100644 tests/read-bundle.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d28e2..d8026f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format follows Keep a Changelog and the package uses semantic versioning. ## Unreleased +## 3.1.0 - 2026-09-25 + +- Added opt-in bounded point-read bundles that reduce cold trie and snapshot + reads to one browser request when the authority advertises the endpoint. +- Kept automatic fallback to the existing encrypted-object path for older + authorities, legacy metadata, and bundles above the four-object or 4 MiB + decoded limits. +- Added explicit covering fields to declared secondary indexes. +- Bounded each covering projection to 64 KiB and every immutable index page to + 4 MiB before changed document objects are written. +- Added typed query projections through `.select(...)`; covered queries can + return declared fields from the index without loading full documents. +- Kept full-document reads for ordinary queries and whenever a predicate, + ordering field, or selected field is not covered. +- Documented embedded and separately deployed authority topologies and their + operational tradeoffs. + ## 3.0.0 - 2026-09-25 - Added the optional ThimbleDB Studio management frontend to the npm package. diff --git a/README.md b/README.md index 04b7626..01e5966 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ Only bindings, credentials, and browser read authorisation differ. - retained deletion, restoration, and quiescent physical collection - immutable snapshot and content-addressed trie collection layouts - typed collections, bounded predicates, and declared secondary indexes +- bounded cold point-read bundles with object-path fallback +- explicit covering index fields and typed projections - versioned logical archives and explicit migration adapters - local application scaffolding and diagnostics - optional package-owned Studio management frontend @@ -102,7 +104,7 @@ Only bindings, credentials, and browser read authorisation differ. - reusable Node and Cloudflare authority endpoint exports - Docker, Wrangler, Bicep, and CloudFormation deployment paths -The reference browser build is about 58.6 KB uncompressed and 16.3 KB gzip. +The reference browser build is about 68.3 KB uncompressed and 18.7 KB gzip. It ships no database runtime or WASM module. ## Quick start @@ -161,6 +163,10 @@ Use the browser/core API from `thimbledb`, external identity primitives from `thimbledb/authority/node` or `thimbledb/authority/cloudflare`. Consumers supply their own domain, storage, OIDC application, and secrets. +The authority can share the application deployment or run as a separate +service behind the same public browser origin. See +[Authority deployment modes](docs/AUTHORITY-DEPLOYMENT.md). + After the authority session exists: ```ts @@ -249,6 +255,7 @@ layout decision thresholds. | [npm publishing](docs/NPM-PUBLISHING.md) | OIDC trusted publisher setup and release process | | [Use cases](docs/USE-CASES.md) | Fit criteria and application-specific guides | | [Architecture](docs/ARCHITECTURE.md) | Components, data flow, and scope model | +| [Authority deployment modes](docs/AUTHORITY-DEPLOYMENT.md) | Embedded and separate authority topologies and decision criteria | | [System diagrams](docs/DIAGRAMS.md) | Trust boundaries, sequences, keys, and providers | | [Storage providers](docs/STORAGE-PROVIDERS.md) | Provider abstraction and conformance requirements | | [Security](docs/SECURITY.md) | Threat model, encryption, keys, and revocation | diff --git a/deploy/cloudflare/wrangler.example.jsonc b/deploy/cloudflare/wrangler.example.jsonc index e75ff63..1552ca3 100644 --- a/deploy/cloudflare/wrangler.example.jsonc +++ b/deploy/cloudflare/wrangler.example.jsonc @@ -15,6 +15,7 @@ "THIMBLE_KEY_VERSION": "1", "THIMBLE_READ_KEY_VERSIONS": "", "THIMBLE_HEAD_TTL_MS": "1000", + "THIMBLE_READ_BUNDLES": "false", "THIMBLE_COLLECTION_LAYOUTS": "", "THIMBLE_COLLECTIONS": "", "THIMBLE_RETIRED_COLLECTION_LAYOUTS": "", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b308af1..fe305dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,6 +7,8 @@ storage abstraction. See [System diagrams](DIAGRAMS.md) for trust boundaries, request sequences, scope separation, and provider layouts. +See [Authority deployment modes](AUTHORITY-DEPLOYMENT.md) for embedded and +separately deployed authority choices. See [Storage providers](STORAGE-PROVIDERS.md) for the provider contract and conformance requirements. @@ -22,6 +24,7 @@ Browser Private read broker session and scope validation encrypted binary envelopes from R2 + optional bounded HTTPS cache-value bundles for cold point reads Browser mutations | @@ -67,21 +70,33 @@ encryption. Private scopes use a versioned AES-256-GCM data key. ## Read path -1. The browser reads `HEAD.json` from memory or IndexedDB. -2. If its TTL expired, the browser revalidates HEAD with `If-None-Match`. -3. A 304 response keeps the current layout generation. -4. ID equality resolves directly to one document path. -5. A matching declared index resolves a bounded set of candidate IDs. -6. Queries without a usable index use an explicitly bounded scan. -7. Trie HEAD points to an immutable root, branch, and leaf path. -8. Snapshot HEAD points to one immutable collection snapshot. -9. The browser coalesces concurrent reads of the same immutable object. -10. It re-evaluates the complete predicate, orders, limits, and returns the +1. The browser first checks the authority-and-scope cache namespace. +2. On a cold point read, a version 3.1 authority can return a bounded HEAD and + immutable-object bundle in one browser request. +3. Older authorities, legacy metadata, oversized bundles, and cache hits use + the individual encrypted-object path. +4. If a cached HEAD TTL expired, the browser revalidates it with + `If-None-Match`. +5. A 304 response keeps the current layout generation. +6. ID equality resolves directly to one document path. +7. A matching declared index resolves a bounded set of candidate IDs. +8. An explicit `.select(...)` can use declared covering fields without + loading full documents. +9. Queries without a usable index use an explicitly bounded scan. +10. Trie HEAD points to an immutable root, branch, and leaf path. +11. Snapshot HEAD points to one immutable collection snapshot. +12. The browser coalesces concurrent reads of the same immutable object. +13. It re-evaluates the complete predicate, orders, limits, and returns the query plan with the documents. Immutable pages do not need revalidation. Their object key identifies their content within the scope and key version. +Individual object reads remain TDB1 envelopes that the browser decrypts. +Bounded read bundles are assembled by the trusted authority and carry decoded +cache values over HTTPS with `no-store`; object storage remains encrypted and +private. + ## Write path 1. The browser sends a mutation to the authority. @@ -89,7 +104,8 @@ content within the scope and key version. 3. Application validation runs before storage work. 4. Changed trie pages or the next immutable snapshot are serialised, gzip-compressed when useful, and encrypted. -5. Every configured secondary index is updated or rebuilt. +5. Every configured secondary index and declared covering projection is + updated or rebuilt. 6. New immutable document and index objects are created. 7. HEAD publishes the document root and all active index references with one ETag compare-and-swap. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 5a26f82..2f006f8 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -106,6 +106,10 @@ account security version, provider, expiry, CSRF token, and issued scope grants. Cookies use HttpOnly, SameSite=Strict, and Secure outside local development. +Embedded and separately deployed authorities should normally remain behind one +public browser origin. See +[Authority deployment modes](AUTHORITY-DEPLOYMENT.md). + On every authenticated request the authority reloads the current internal user record and recalculates grants. Role or tenant removal observed during a later OIDC exchange therefore also affects existing sessions. @@ -235,6 +239,7 @@ grants. `thimble.admin` does not make ungranted data scopes visible. | `POST /api/auth/identities/unlink` | Recent session + CSRF | Remove a non-final identity and revoke sessions | | `GET /api/config` | Session | Current user, scope, CSRF, and cache config | | `GET /api/keys/:scope` | Session + read grant | Scope key grant | +| `GET /api/read-bundles/:scope/:collection/:id` | Session + read grant | Optional bounded cold point-read bundle | | `GET /api/admin/users` | `thimble.admin` | List identity mappings | | `POST /api/admin/users/:id` | `thimble.admin` + CSRF | Change status, application roles, or tenants | | `POST /api/admin/users/:id/revoke-sessions` | `thimble.admin` + CSRF | Revoke every user session | diff --git a/docs/AUTHORITY-DEPLOYMENT.md b/docs/AUTHORITY-DEPLOYMENT.md new file mode 100644 index 0000000..36ebf63 --- /dev/null +++ b/docs/AUTHORITY-DEPLOYMENT.md @@ -0,0 +1,201 @@ +# Authority deployment modes + +ThimbleDB separates the browser client from the authenticated authority. The +authority can run in the application's deployment or as a separately operated +Worker, container, or function. + +This is a process and deployment choice. In both modes, expose application and +authority routes through one public browser origin unless an independently +reviewed cross-origin session design replaces the default Strict cookie +contract. + +## Decision summary + +| Consideration | Embedded authority | Separate authority service | +| --- | --- | --- | +| Deployment units | One application deployment | Application plus authority deployment | +| Browser origin | Naturally the same | Use a gateway or path route to preserve one public origin | +| Secrets | Application runtime also holds authority and storage secrets | Storage credentials and master key remain outside the application runtime | +| Release cadence | Application and authority change together | Authority can be upgraded independently | +| Scaling | Application and authority scale together | Reads, writes, and application rendering can scale separately | +| Failure boundary | One runtime can affect both application and data API | Application and authority failures are isolated | +| Operations | Simplest setup and observability | More routing, monitoring, version coordination, and incident paths | +| Latency | No service-to-service hop inside the deployment | Gateway and service routing may add latency | +| Best fit | One small application and one operations team | Shared platform boundary, stricter secret isolation, or independently scaled authority | + +The storage provider does not determine the topology. A Node authority can use +local files, Azure Blob Storage, S3, or R2 in either deployment model. A +Cloudflare authority can share one Worker deployment with application assets +or run as a dedicated routed Worker. + +## Embedded authority + +The authority starts as part of the application deployment. The application +and data API normally share one release, hostname, logs, and scaling policy. + +Cloudflare example: + +```ts +import { + createCloudflareAuthority, +} from "thimbledb/authority/cloudflare"; +import { + collectionIndexes, + collectionLayouts, +} from "./collections"; + +export default createCloudflareAuthority({ + studio: true, + readBundles: true, + collections: ["notes"], + collectionIndexes, + collectionLayouts, +}); +``` + +The same Worker can serve static assets through an `ASSETS` binding and run +the authority for `/api/*`. + +Node example: + +```ts +import { + startNodeAuthority, +} from "thimbledb/authority/node"; + +await startNodeAuthority({ + studio: true, + readBundles: true, + collections: ["notes"], + collectionIndexes, + collectionLayouts, +}); +``` + +The application deployment owns the authority process. A gateway can expose +the application frontend and authority listener through one public origin. + +Choose this mode when: + +- one application owns the data model +- one deployment lifecycle is acceptable +- the smallest operational surface is more important than secret isolation +- application and authority traffic have similar scaling needs +- a same-origin browser path should require no additional routing layer + +Avoid it when a compromise of the application runtime must not expose the +storage credential or deployment master key. + +## Separate authority service + +The authority runs in its own Worker, container, Lambda function, Container +App, or Node service. The browser application remains a normal ThimbleDB +client. + +Typical public routing: + +```text +https://app.example.com/ -> application assets or application server +https://app.example.com/api/* -> separate ThimbleDB authority +https://app.example.com/studio/* -> authority or version-matched Studio assets +``` + +The authority service owns: + +- OIDC token exchange and opaque sessions +- CSRF and exact Origin enforcement +- scope grants and key grants +- read bundles and encrypted-object broker routes +- validation, writes, retained deletion, and maintenance +- storage credentials and the deployment master key + +The application runtime needs none of those storage secrets. + +Choose this mode when: + +- application and authority releases need independent approval or rollback +- storage credentials require a smaller runtime trust boundary +- several application processes use one authority contract +- write and broker traffic need independent scaling or observability +- platform routing already supports path-based service isolation + +The additional cost is real: another deployment, route, health check, log +stream, alert set, version boundary, and incident path must be operated. + +## Same-origin browser boundary + +The default ThimbleDB session cookie is `HttpOnly` and `SameSite=Strict`. +Browser caches, BroadcastChannel logout, localStorage cache registries, and +IndexedDB are origin-scoped. For that reason, a separate process should not +automatically imply a separate browser hostname. + +`THIMBLE_ALLOWED_ORIGIN` validates state-changing requests. It does not by +itself turn the default browser client into a cross-origin cookie system. + +Prefer a reverse proxy, Worker route, Function URL gateway, Front Door route, +or application gateway that preserves one public origin. Review all of the +following before intentionally introducing a separate authority origin: + +- cookie `SameSite`, `Secure`, and domain attributes +- credentialed CORS responses and preflight behaviour +- CSRF and exact Origin validation +- cache namespace and logout coordination across origins +- Studio hosting and session behaviour +- redirect and callback URLs at the OIDC provider + +## Performance implications + +Read bundles work in both deployment modes because the browser discovers the +optional endpoint from `/api/config`. + +Enable them explicitly with `readBundles: true` or +`THIMBLE_READ_BUNDLES=true`. Existing deployments retain the individual TDB1 +object path until that capability is enabled. + +The trusted authority assembles bundle cache values after decrypting storage +objects and sends them over HTTPS with `no-store`. Leave the capability +disabled if the deployment requires every read response above TLS to remain a +TDB1 envelope. + +An embedded authority removes one internal routing boundary. A separate +authority can instead be placed near object storage and scaled independently. +Neither choice changes the number of browser requests once the same public +route reaches the authority. + +Measure: + +- browser-to-authority latency +- authority-to-object-storage latency +- cold read-bundle duration and fallback count +- session and key-grant duration +- conditional-write conflicts +- application and authority CPU independently + +Do not claim one topology is faster without testing the actual gateway, +runtime, and storage region. + +## Security implications + +Both modes enforce the same sessions, scope grants, encryption, deletion, and +conditional-write rules. + +Embedded mode has a larger runtime blast radius because application server +code and authority secrets coexist. Separate mode narrows that secret boundary +but adds routing and service-to-service configuration that can itself be +misconfigured. + +In either mode: + +- keep object storage private +- expose reads only through the authenticated broker or bounded bundle route +- keep provider credentials and the master key out of browser code +- use exact allowed origins +- keep application and authority package versions compatible +- use logical exports and provider backups independently of deployment shape + +## Recommendation + +Start embedded for one small application unless a concrete security, +operations, or scaling requirement justifies a separate authority. Move the +authority into a separate service without changing application collection +code, storage layout, or browser query semantics. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 7f102d9..ad5567d 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -62,6 +62,13 @@ with a ten-second HEAD TTL. - The result supports adaptive layout selection for this workload. It does not establish superiority over another database. +Version 3.1 adds an optional bounded point-read bundle. Automated browser tests +verify that an eligible cold trie point read uses one browser request instead +of four and that unsupported or oversized bundles fall back to the original +object path. No new live multi-region latency result has yet been published, +so the request reduction is verified but its production p95 effect remains to +be measured. + ## Measurements not covered The published runs do not cover: diff --git a/docs/DEPLOYMENT-AWS.md b/docs/DEPLOYMENT-AWS.md index ed84271..e82e2e8 100644 --- a/docs/DEPLOYMENT-AWS.md +++ b/docs/DEPLOYMENT-AWS.md @@ -8,6 +8,10 @@ AWS is a secondary deployment path. The example uses: The CloudFormation template is `deploy/aws/template.yaml`. +The Lambda authority can be deployed with the application or as a separate +service behind the same public application gateway. See +[Authority deployment modes](AUTHORITY-DEPLOYMENT.md). + ## Build the Lambda image Build the Lambda-compatible image, which includes the pinned AWS Lambda Web @@ -59,6 +63,9 @@ To serve the package-owned Studio from the same authority, set `THIMBLE_STUDIO=true` and configure the exact public origin in `THIMBLE_STUDIO_ORIGIN`. +Set `THIMBLE_READ_BUNDLES=true` to advertise the bounded cold point-read +optimization. + For key rotation, deploy `KeyVersion` as the current write version and `ReadKeyVersions` as the comma-separated historical versions that remain readable. @@ -89,6 +96,8 @@ skipped rather than collapsing all users onto the adapter loopback address. ## Verify - Function URL serves the application and `/api/config`. +- `/api/config` advertises the optional bounded read-bundle route. +- An eligible cold point read uses one browser request. - S3 objects are private from the S3 endpoint. - Brokered private object bodies start with `TDB1`. - The auth bucket is never browser-readable. diff --git a/docs/DEPLOYMENT-AZURE.md b/docs/DEPLOYMENT-AZURE.md index 616a5e2..d4bd6b0 100644 --- a/docs/DEPLOYMENT-AZURE.md +++ b/docs/DEPLOYMENT-AZURE.md @@ -9,6 +9,10 @@ Azure is a secondary deployment path using: The Bicep template is `deploy/azure/main.bicep`. +The Container App authority can share an application deployment boundary or +run independently behind Front Door or another same-origin gateway. See +[Authority deployment modes](AUTHORITY-DEPLOYMENT.md). + ## Prerequisites - Azure CLI @@ -83,6 +87,9 @@ To serve the package-owned Studio from the same Container App, set `THIMBLE_STUDIO=true` and set `THIMBLE_STUDIO_ORIGIN` to the exact public authority origin. +Set `THIMBLE_READ_BUNDLES=true` to advertise the bounded cold point-read +optimization. + For key rotation, set `keyVersion` to the current write version and `readKeyVersions` to the comma-separated historical versions that remain readable. @@ -92,6 +99,7 @@ readable. - Container App uses HTTPS. - The auth container is not exposed through any SAS or public endpoint. - Browser object requests use the authenticated `/api/objects` broker. +- Eligible cold point reads use the advertised bounded read-bundle route. - Object bodies begin with `TDB1`. - The Container App can seed and mutate data. - Direct browser reads cannot write or delete blobs. diff --git a/docs/DEPLOYMENT-CLOUDFLARE.md b/docs/DEPLOYMENT-CLOUDFLARE.md index 7f17690..cebff33 100644 --- a/docs/DEPLOYMENT-CLOUDFLARE.md +++ b/docs/DEPLOYMENT-CLOUDFLARE.md @@ -2,6 +2,11 @@ Cloudflare Workers and R2 are the reference ThimbleDB deployment. +The authority can share the application Worker deployment or run as a +separately routed Worker. See +[Authority deployment modes](AUTHORITY-DEPLOYMENT.md). In both modes, prefer +one public browser origin. + The design uses: - one Worker for static assets, writes, sessions, and scope-key grants @@ -21,7 +26,7 @@ npm run build:client npm run build:worker ``` -The Worker bundle is about 33.8 KB gzip. Track bundle growth before +The Worker bundle is about 42.9 KB gzip. Track bundle growth before deploying an upgrade. ## 2. Authenticate Wrangler @@ -44,7 +49,7 @@ applies to Standard storage. Never attach a custom domain to the auth bucket. ## 4. Configure the application domain -Use one Worker hostname: +For the embedded reference deployment, use one Worker hostname: ```text db.example.com @@ -53,6 +58,10 @@ db.example.com Object reads pass through the Worker and require a valid session and scope grant. No R2 custom domain or browser CORS rule is required. +For a separate authority Worker, route `/api/*` and `/studio/*` from the +application hostname to that Worker rather than introducing a second browser +origin by default. + ## 5. Create Wrangler configuration Copy the disabled example beside the original so its relative paths remain @@ -106,6 +115,7 @@ Enable the Studio API in the authority factory or set: ```text THIMBLE_STUDIO=true THIMBLE_STUDIO_ORIGIN=https://database.example.com +THIMBLE_READ_BUNDLES=true ``` Build the browser application and copy the package-owned Studio assets after @@ -149,13 +159,15 @@ npx wrangler deploy --config deploy\cloudflare\wrangler.local.jsonc 3. Seed the tiny store. 4. Confirm `/api/config` returns provider `r2`. 5. Confirm private object GETs use `/api/objects/scopes/...`. -6. Confirm raw object bodies start with `TDB1` and contain no plaintext JSON. -7. Confirm a second HEAD request returns 304. -8. Confirm the browser key is non-extractable. -9. Confirm logout revokes the session and blocks brokered reads. -10. Delete and restore a test document. -11. Confirm administrator user listing is restricted to `thimble.admin`. -12. Enable maintenance mode in a non-production scope and verify writes return +6. Confirm `/api/config` advertises `/api/read-bundles`. +7. Confirm an eligible cold point read uses one bounded bundle request. +8. Confirm raw object bodies start with `TDB1` and contain no plaintext JSON. +9. Confirm a second HEAD request returns 304. +10. Confirm the browser key is non-extractable. +11. Confirm logout revokes the session and blocks brokered reads. +12. Delete and restore a test document. +13. Confirm administrator user listing is restricted to `thimble.admin`. +14. Enable maintenance mode in a non-production scope and verify writes return `503 maintenance_mode`. ## 10. Operations diff --git a/docs/DIAGRAMS.md b/docs/DIAGRAMS.md index cb2cdae..bdadbcb 100644 --- a/docs/DIAGRAMS.md +++ b/docs/DIAGRAMS.md @@ -1,6 +1,6 @@ # System diagrams -These Mermaid diagrams describe the ThimbleDB 2.1 data, identity, query, +These Mermaid diagrams describe the ThimbleDB 3.1 data, identity, query, index, migration, and deployment paths. ## Trust boundaries @@ -68,6 +68,73 @@ flowchart LR The browser receives decrypt-only scope keys. It never receives a storage credential, an authority write key, or a database-wide administrator key. +## Embedded authority deployment + +```mermaid +flowchart LR + Browser["Browser application"] + OIDC["External OIDC provider"] + + subgraph Deployment["One application deployment"] + Assets["Application and Studio assets"] + Authority["ThimbleDB authority
sessions + broker + writes"] + App["Application server or Worker logic"] + end + + Data["Private application object storage"] + Auth["Separate private auth storage"] + Secrets["Master key and provider credentials"] + + Browser --> Assets + Browser --> OIDC --> Browser + Browser --> App + Browser --> Authority + Authority --> Data + Authority --> Auth + Secrets --> Authority +``` + +Embedded mode keeps one deployment, release cadence, public origin, and +scaling policy. Application server code and authority secrets share one +runtime trust boundary. + +## Separate authority deployment + +```mermaid +flowchart LR + Browser["Browser application"] + OIDC["External OIDC provider"] + + subgraph Origin["One public browser origin"] + Gateway["Path router or application gateway"] + end + + App["Application assets or application service"] + + subgraph AuthorityService["Separate authority deployment"] + Authority["ThimbleDB authority"] + Broker["Object broker and read bundles"] + Mutation["Validation and conditional writes"] + end + + Data["Private application object storage"] + Auth["Separate private auth storage"] + Secrets["Authority-only secrets"] + + Browser --> OIDC --> Browser + Browser --> Gateway + Gateway -- "/" --> App + Gateway -- "/api/* and /studio/*" --> Authority + Authority --> Broker --> Data + Authority --> Mutation --> Data + Authority --> Auth + Secrets --> Authority +``` + +Separate mode isolates secrets, failures, releases, and scaling while the path +router preserves Strict cookies, CSRF, cache namespaces, and logout +coordination under one browser origin. + ## Connection and key-grant sequence ```mermaid @@ -105,10 +172,14 @@ flowchart TD Query["Typed query expression"] Validate["Validate version, values, limits, depth, and scan bound"] Point{"ID equality?"} + Bundle{"Cold cache and bundle endpoint available?"} + BundleRead["One bounded authority read bundle"] Index{"Matching declared index with indexable values?"} Head["Read collection HEAD"] IndexPage["Read immutable encrypted index page"] Candidates["Resolve bounded candidate IDs"] + Covered{"Explicit selected fields and all query fields covered?"} + Projection["Read declared projections from index page"] ReadDocs["Read candidate documents
coalesce shared immutable reads"] Predicate["Re-evaluate complete predicate"] Scan["Read bounded collection"] @@ -116,9 +187,13 @@ flowchart TD Result["Return documents and plan metadata"] Query --> Validate --> Point - Point -- Yes --> ReadDocs + Point -- Yes --> Bundle + Bundle -- Yes --> BundleRead --> Result + Bundle -- No or fallback --> ReadDocs Point -- No --> Index - Index -- Yes --> Head --> IndexPage --> Candidates --> ReadDocs + Index -- Yes --> Head --> IndexPage --> Candidates --> Covered + Covered -- Yes --> Projection --> Predicate + Covered -- No --> ReadDocs Index -- No --> Scan ReadDocs --> Predicate --> Order --> Result Scan --> Predicate @@ -141,7 +216,12 @@ sequenceDiagram Client->>Client: Validate query and choose point, index, or scan plan Client->>Cache: Read collection HEAD - alt HEAD missing or stale + alt Cold point read and bundle endpoint advertised + Client->>Broker: GET bounded point-read bundle + Broker->>Broker: Require current read grant and enforce object/byte limits + Broker-->>Client: Decoded HEAD and immutable cache values over HTTPS + Client->>Cache: Store returned objects + else HEAD missing or stale Client->>Broker: GET encrypted HEAD with session Broker->>Store: Read object with ETag condition Store-->>Broker: Encrypted HEAD @@ -153,12 +233,17 @@ sequenceDiagram Client->>Cache: Read referenced immutable index page Client->>Broker: Fetch index page on cache miss Client->>Client: Resolve candidate IDs within maxScan + alt Explicit projection is covered + Client->>Client: Validate predicate and order from declared projections + Client->>Client: Build projected documents without full-document reads + else Full documents required + Client->>Cache: Resolve snapshot once or shared trie nodes + Client->>Broker: Fetch only missing immutable objects + end else Scan plan Client->>Client: Enforce bounded collection scan end - Client->>Cache: Resolve snapshot once or shared trie nodes - Client->>Broker: Fetch only missing immutable objects Client->>Client: Decrypt, validate candidates, order, and limit Client-->>App: Documents plus point/index/scan plan ``` diff --git a/docs/EVALUATION.md b/docs/EVALUATION.md index d0bf857..715c87b 100644 --- a/docs/EVALUATION.md +++ b/docs/EVALUATION.md @@ -34,7 +34,13 @@ The UI can: - link external identities and use administrator controls - compare trie and snapshot collection behaviour - display remote reads, transferred bytes, cache hits, ETag 304 responses, - offline fallbacks, and retained memory + read-bundle requests and fallbacks, offline fallbacks, and retained memory + +Enable the opt-in cold point-read bundle path with: + +```powershell +$env:THIMBLE_READ_BUNDLES = "true" +``` For a compiled local run: diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 1ece867..0288934 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -46,6 +46,8 @@ The scaffold includes: - encrypted local user scope - typed notes collection - title and modification-time indexes +- explicit covering fields for the indexed title result +- bounded point-read bundles on cold cache misses - indexed lookup and ordering - deletion and restore - Vite development server diff --git a/docs/FAQ.md b/docs/FAQ.md index 7c76627..7ca3d71 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -36,6 +36,19 @@ includes a Node authority with local filesystem, Azure Blob Storage, Amazon S3, and S3-compatible adapters. The stored protocol remains the same across providers. +## Does the authority run inside the application? + +It can. The authority can share the application deployment or run as a +separate Worker, container, function, or Node service. A separate process +should normally remain behind the same public browser origin through +path-based routing so Strict cookies, CSRF, Studio, browser caches, and logout +coordination retain the documented behaviour. + +Use an embedded authority for the smallest operational surface. Use a separate +authority when storage-secret isolation, independent release control, failure +isolation, or independent scaling justifies another service. See +[Authority deployment modes](AUTHORITY-DEPLOYMENT.md). + ## Does ThimbleDB store passwords? No. Applications use Microsoft Entra or another OpenID Connect provider. @@ -78,6 +91,10 @@ the tested regions. The benchmark supports browser caching and adaptive snapshot selection for that workload. It does not establish general superiority over another database. +Version 3.1 can reduce an eligible cold point read to one bounded browser +request. The request-count reduction is tested, but updated live regional +latency evidence has not yet been published. + ## Is it suitable for vibe-coded applications? It can fit focused applications that use a small number of JSON record types, @@ -106,6 +123,10 @@ equality, range, or composite indexes. A typed fluent query compiles to a serialisable expression and uses a matching immutable index page when available. ID equality remains a direct point read. +Indexes may declare bounded covering fields. An explicit `.select(...)` query +can return those fields without full-document reads when every predicate, +ordering, and selected field is covered. + ThimbleDB does not provide joins, aggregates, cross-scope queries, automatic indexing of every field, or a general distributed query engine. diff --git a/docs/IMPLEMENTATION-PROMPTS.md b/docs/IMPLEMENTATION-PROMPTS.md index 2711021..8720e27 100644 --- a/docs/IMPLEMENTATION-PROMPTS.md +++ b/docs/IMPLEMENTATION-PROMPTS.md @@ -7,13 +7,14 @@ grant the tool access to cloud credentials or production secrets. ## Integrate ThimbleDB into an existing web application ```text -Integrate ThimbleDB 1.x into this existing TypeScript web application. +Integrate ThimbleDB 3.x into this existing TypeScript web application. Application context: - Framework: [framework] - Package manager: [package manager] - OIDC provider: [Entra/Auth0/other] - Authority platform: [Cloudflare/Node] +- Authority topology: [embedded deployment/separate service behind same-origin route] - Collections: [collection list] - Scope model: [per-user/per-tenant/both] @@ -23,13 +24,16 @@ Requirements: 3. Use external OIDC authentication. Do not add local passwords, password hashes, recovery tokens, passkey storage, or MFA secrets to ThimbleDB. 4. Exchange the provider access token at `/api/auth/oidc//session`. 5. Build the browser client from `/api/config` and the scope key grant. -6. Keep provider credentials and object-storage credentials server-side. -7. Keep scope keys memory-only as non-extractable CryptoKeys. -8. Pass `collectionLayouts`, `layoutGeneration`, and `configurationUrl` to `ThimbleClient`. -9. Preserve CSRF and `x-thimble-layout-generation` headers on mutations. -10. Add typed helpers for get, scan, write, delete, restore, and logout. -11. Add tests for authentication failure, scope isolation, stale layout generation, deletion/restore, and logout cache clearing. -12. Update the application's setup documentation with non-secret configuration only. +6. Preserve one public browser origin whether the authority is embedded or + separately deployed. +7. Keep provider credentials and object-storage credentials server-side. +8. Keep scope keys memory-only as non-extractable CryptoKeys. +9. Pass `collectionLayouts`, `layoutGeneration`, and `configurationUrl` to `ThimbleClient`. +10. Preserve CSRF and `x-thimble-layout-generation` headers on mutations. +11. Use explicit covering fields and `.select(...)` only for bounded projections that avoid full-document reads. +12. Add typed helpers for get, scan, write, delete, restore, and logout. +13. Add tests for authentication failure, scope isolation, stale layout generation, read-bundle fallback, deletion/restore, and logout cache clearing. +14. Update the application's setup documentation with non-secret configuration only. Do not expose R2/S3/Blob directly to the browser. Do not auto-link identities by email. Do not use undocumented package internals. @@ -39,7 +43,7 @@ Run the smallest applicable tests, type-check, and production build. Report exac ## Deploy the Cloudflare authority ```text -Deploy ThimbleDB 1.x as a Cloudflare Worker with private R2 storage. +Deploy ThimbleDB 3.x as a Cloudflare Worker with private R2 storage. Inputs: - Worker name: [name] @@ -61,8 +65,9 @@ Requirements: 7. Configure 30-day deletion retention, 7-day purge grace, and maintenance mode off. 8. Apply lifecycle expiration only to auth sessions and rate-limit records. 9. Do not apply age-based deletion to application data objects. -10. Keep bindings, routes, and infrastructure placeholders disabled until real values are supplied. -11. Deploy, then verify session exchange, encrypted object reads, conditional HEAD, write, delete, restore, logout revocation, and stale layout rejection. +10. Enable bounded read bundles only after accepting the documented trusted-authority plaintext boundary. +11. Keep bindings, routes, and infrastructure placeholders disabled until real values are supplied. +12. Deploy, then verify session exchange, bounded bundle and fallback reads, encrypted object reads, conditional HEAD, write, delete, restore, logout revocation, and stale layout rejection. Do not invent account IDs, bucket names, tenant IDs, audiences, routes, or secrets. Stop and report any missing non-secret value instead of deploying a placeholder. ``` @@ -89,8 +94,9 @@ Requirements: 8. If source IP cannot be verified, disable IP limits and retain subject limits. 9. Keep the master key and provider credentials in the platform secret store. 10. Expose only the authority HTTP port. -11. Add health, authentication, indexed query, write, deletion, and logout smoke tests. -12. Document backup, logical export, key rotation, retention maintenance, index migration, and layout migration. +11. Enable bounded read bundles only after accepting the documented trusted-authority plaintext boundary. +12. Add health, authentication, bundled point read, indexed projection, write, deletion, and logout smoke tests. +13. Document backup, logical export, key rotation, retention maintenance, index migration, and layout migration. Do not create a second authentication system. Do not store passwords or provider access tokens. For machine access, prefer an OIDC service principal with explicit roles. Do diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 211aa9b..6eae6cf 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -84,7 +84,7 @@ $env:THIMBLE_MIGRATION_QUIESCENT = "true" $env:THIMBLE_SCOPE_ID = "user:" $env:THIMBLE_COLLECTIONS = "notes" $env:THIMBLE_COLLECTION_LAYOUTS = "notes=snapshot" -$env:THIMBLE_COLLECTION_INDEXES = '{"notes":[{"name":"by-title","fields":["title"],"mode":"equality"}]}' +$env:THIMBLE_COLLECTION_INDEXES = '{"notes":[{"name":"by-title","fields":["title"],"mode":"equality","include":["lastModified"]}]}' npx thimbledb rebuild-indexes ``` @@ -94,6 +94,7 @@ through the collection HEAD, and verifies full document equality. Other rewrite operations preserve the complete active index definition set and fail if `THIMBLE_COLLECTION_INDEXES` is absent, partial, or mismatched. Use the explicit index migration when removing or redefining an index. +Adding or changing covering `include` fields is an index redefinition. See [Queries and secondary indexes](QUERIES-INDEXES.md). @@ -141,6 +142,7 @@ Record: - read source: memory, IndexedDB, or remote - remote object bytes +- read-bundle requests, bytes, object counts, and fallbacks - compression ratio - envelope encode/decode duration - HEAD conditional-write retries diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index e661341..9242d01 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -61,7 +61,8 @@ The optional `indexes` object maps each declared index name to one immutable encrypted index page and its tuple count. Document and index references are published together through the collection HEAD compare-and-swap. -An index page contains its complete definition and sorted scalar tuples: +An index page contains its complete definition and sorted scalar tuples. +Version 3.1 pages may also contain explicit covering projections: ```json { @@ -69,19 +70,35 @@ An index page contains its complete definition and sorted scalar tuples: "definition": { "name": "by-title", "fields": ["title"], - "mode": "equality" + "mode": "equality", + "include": ["lastModified"] }, "entries": [ { "values": ["First note"], "ids": ["note-1"] } - ] + ], + "projections": { + "note-1": { + "id": "note-1", + "title": "First note", + "lastModified": 1790300000000 + } + } } ``` Only string, finite number, boolean, and null values are indexed. Arrays and -objects remain available to bounded query evaluation. +objects remain available to bounded query evaluation. Covering projections +contain only `id`, index key fields, and up to eight explicitly declared +included fields. Each projection is limited to 64 KiB decoded. Ordinary +queries ignore projections and still load complete documents. + +Every complete index page is limited to 4 MiB decoded. The authority validates +the page before writing changed document objects, and browser clients use the +authenticated HEAD `decodedBytes` value to avoid downloading oversized or +legacy index pages. Private node addresses use HMAC-SHA-256 with a scope-derived address key. Public deployments still use stable opaque addresses, but confidentiality is @@ -177,6 +194,31 @@ A successful write response contains: This removes a read-after-write round trip and lets other tabs update through BroadcastChannel. +## Bounded point-read bundle + +Version 3.1 authorities with `readBundles: true` or +`THIMBLE_READ_BUNDLES=true` advertise `/api/read-bundles` through +`/api/config`. On a cold point read, the browser can request: + +```text +GET /api/read-bundles/// +``` + +The authority: + +1. authenticates the current session +2. requires an explicit read grant for the path scope +3. resolves the current snapshot or trie document path +4. rejects bundles above four decoded objects or 4 MiB +5. returns the same HEAD and immutable values used by browser caches + +The response uses `cache-control: no-store`. It is a transport optimization, +not a new source of truth or storage layout. + +Clients automatically use the individual encrypted-object broker when the +endpoint is absent, the collection lacks authenticated size metadata, or the +bundle exceeds its limits. Older clients ignore the advertised endpoint. + ## Compatibility fixtures The envelope magic and version are durable protocol fields. Before a public diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index a9e9e0f..6cac8c8 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -37,19 +37,44 @@ Typed collection: ```ts const notes = defineCollection("notes", { indexes: [ - defineIndex("by-title", ["title"]), + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["lastModified"] }, + ), ], }); +const noteSummarySchema = { + parse(value: unknown): Pick< + Note, + "id" | "title" | "lastModified" + > { + return value as Pick< + Note, + "id" | "title" | "lastModified" + >; + }, +}; + const result = await db .collection(notes) .where((note) => note.title.eq("abc")) .take(25) + .select( + ["title", "lastModified"], + noteSummarySchema, + ) .get(); ``` See [Queries and secondary indexes](QUERIES-INDEXES.md). +Connections created by `createThimbleClient()` use an advertised bounded +point-read bundle endpoint on cold cache misses. They automatically retain the +individual object path for older or oversized deployments. + ### `thimbledb/auth` The auth export contains: @@ -70,6 +95,7 @@ import { startNodeAuthority } from "thimbledb/authority/node"; await startNodeAuthority({ studio: true, + readBundles: true, studioOrigin: "https://database.example.com", collections: ["notes"], collectionLayouts: { @@ -84,6 +110,9 @@ authentication, object broker, key grant, write, deletion, linking, administration, retention, and layout-migration endpoints used by the reference deployment. +See [Authority deployment modes](AUTHORITY-DEPLOYMENT.md) before choosing one +application deployment or a separately operated authority service. + ### `thimbledb/authority/cloudflare` ```ts @@ -93,6 +122,7 @@ import { export default createCloudflareAuthority({ studio: true, + readBundles: true, studioOrigin: "https://database.example.com", collections: ["notes"], collectionLayouts: { diff --git a/docs/QUERIES-INDEXES.md b/docs/QUERIES-INDEXES.md index cbc5361..5a39273 100644 --- a/docs/QUERIES-INDEXES.md +++ b/docs/QUERIES-INDEXES.md @@ -36,6 +36,9 @@ export const notes = defineCollection("notes", noteSchema, { "by-last-modified", ["lastModified"], "range", + { + include: ["title"], + }, ), ], }); @@ -190,6 +193,78 @@ four fields. Only scalar string, number, boolean, or null values are indexed. Arrays and objects remain available to bounded local filtering. +## Explicit covering fields + +An index can include up to eight additional document fields: + +```ts +defineIndex( + "by-title", + ["title"], + "equality", + { + include: ["body", "lastModified"], + }, +); +``` + +Use an explicit typed projection to opt into the covering path: + +```ts +const noteCardSchema = { + parse(value: unknown): Pick< + Note, + "id" | "title" | "body" | "lastModified" + > { + // Validate with Zod or another schema in a real application. + return value as Pick< + Note, + "id" | "title" | "body" | "lastModified" + >; + }, +}; + +const cards = await notes + .where((note) => note.title.eq("abc")) + .orderBy((note) => note.lastModified.desc()) + .take(25) + .select( + ["title", "body", "lastModified"], + noteCardSchema, + ) + .get(); +``` + +The result documents contain `id` and the selected fields. The index can +answer the query without full-document reads only when all predicate, +ordering, and selected fields are either index key fields or declared +`include` fields. + +The projection schema is required even when the index covers the query. +ThimbleDB does not cast unvalidated index values to the application type. +Zod-compatible `parse(value)` schemas work without adding Zod as a ThimbleDB +runtime dependency. + +Each stored covering projection is limited to 64 KiB decoded. A write fails +explicitly if the declared fields exceed that bound; choose smaller list-view +fields instead of including large bodies or binary-like JSON values. + +The complete immutable index page is limited to 4 MiB decoded. Index pages are +built and checked before changed document objects are written. Oversized +definitions fail with `413 secondary_index_too_large`. + +ThimbleDB loads complete documents when: + +- `.select(...)` is not used +- any selected field is not covered +- any predicate field is not covered +- any ordering field is not covered +- the active index page predates or disagrees with the covering definition + +This preserves schema validation and full-document behaviour for existing +queries. Adding or changing `include` fields is an index-definition change and +requires the explicit index rebuild process. + ## Query plans Inspect the planned operation before running it: diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 9a45bc7..cf2a8fd 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -10,6 +10,15 @@ Choose one authority: The browser API is the same for every authority. +Choose one deployment shape: + +- embed the authority in the application deployment +- run the authority in a separate Worker, container, function, or Node service + +In either case, preserve one public browser origin through the application +server or a path-based gateway. See +[Authority deployment modes](AUTHORITY-DEPLOYMENT.md). + ## Fast local evaluation Generate a complete local Node authority and Vite application: @@ -97,13 +106,14 @@ Use a Wrangler configuration with caller-owned resources: "THIMBLE_KEY_VERSION": "1", "THIMBLE_READ_KEY_VERSIONS": "", "THIMBLE_HEAD_TTL_MS": "10000", + "THIMBLE_READ_BUNDLES": "true", "THIMBLE_COLLECTION_LAYOUTS": "", "THIMBLE_COLLECTION_INDEXES": "{}", "THIMBLE_RETIRED_COLLECTION_LAYOUTS": "", "THIMBLE_DELETE_RETENTION_DAYS": "30", "THIMBLE_DELETE_GRACE_DAYS": "7", "THIMBLE_MAINTENANCE_MODE": "false", - "THIMBLE_ALLOWED_ORIGIN": "https://app.example.com", + "THIMBLE_ALLOWED_ORIGIN": "https://database.example.com", "ENTRA_TENANT_ID": "", "ENTRA_AUDIENCE": "", "ENTRA_REQUIRED_SCOPE": "thimble.access", @@ -121,7 +131,7 @@ Use a Wrangler configuration with caller-owned resources: ], "routes": [ { - "pattern": "db.example.com", + "pattern": "database.example.com", "custom_domain": true } ] @@ -159,7 +169,9 @@ Create `server.mjs`: ```js import { startNodeAuthority } from "thimbledb/authority/node"; -await startNodeAuthority(); +await startNodeAuthority({ + readBundles: true, +}); ``` For local development: @@ -226,7 +238,14 @@ type Note = { const notes = defineCollection("notes", { indexes: [ - defineIndex("by-title", ["title"]), + defineIndex( + "by-title", + ["title"], + "equality", + { + include: ["body", "lastModified"], + }, + ), defineIndex( "by-last-modified", ["lastModified"], @@ -235,17 +254,37 @@ const notes = defineCollection("notes", { ], }); +const noteCardSchema = { + parse(value: unknown): Pick< + Note, + "id" | "title" | "body" | "lastModified" + > { + return value as Pick< + Note, + "id" | "title" | "body" | "lastModified" + >; + }, +}; + const result = await db .collection(notes) .where((note) => note.title.eq("First note")) .orderBy((note) => note.lastModified.desc()) .take(25) + .select( + ["title", "body", "lastModified"], + noteCardSchema, + ) .get(); ``` The authority must configure the same indexes. See [Queries and secondary indexes](QUERIES-INDEXES.md). +On an eligible cold point read, the ready client uses the bounded bundle +endpoint advertised by a version 3.1 authority. Older or oversized +deployments automatically use the individual object path. + ## Advanced manual client construction Applications that need custom readers or caches can construct every component @@ -255,6 +294,7 @@ directly: import { EnvelopeJsonObjectReader, HttpByteObjectReader, + HttpPointReadBundleReader, IndexedDbObjectCache, MemoryObjectCache, ScopedJsonObjectReader, @@ -302,6 +342,14 @@ const cache = new TieredObjectCache( const db = new ThimbleClient({ reader, + ...(config.readBundleBaseUrl + ? { + bundleReader: new HttpPointReadBundleReader( + config.readBundleBaseUrl, + config.scope.id, + ), + } + : {}), cache, headTtlMs: config.headTtlMs, csrfToken: config.csrfToken, @@ -338,11 +386,13 @@ Confirm: 2. The session cookie is HttpOnly, SameSite=Strict, and Secure in production. 3. `/api/config` returns the expected internal user scope. 4. Brokered objects begin with `TDB1`. -5. R2, S3, or Blob credentials never reach the browser. -6. Scope keys exist only as non-extractable in-memory CryptoKeys. -7. Logout blocks later object reads and clears the browser cache. -8. Delete and restore follow the configured retention window. -9. A stale layout generation receives `409 layout_changed`. +5. Eligible cold point reads use one bounded bundle request and oversized + bundles fall back to the object path. +6. R2, S3, or Blob credentials never reach the browser. +7. Scope keys exist only as non-extractable in-memory CryptoKeys. +8. Logout blocks later object reads and clears the browser cache. +9. Delete and restore follow the configured retention window. +10. A stale layout generation receives `409 layout_changed`. Continue with [Authentication](AUTHENTICATION.md), [Local development](DEVELOPMENT.md), diff --git a/docs/README.md b/docs/README.md index 66c1ad5..24faafe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ | [Use cases](USE-CASES.md) | Fit criteria and application-specific guides | | [Database comparisons](COMPARISONS.md) | Workload comparisons with D1, SQLite, Firestore, lowdb, and direct object storage | | [Architecture](ARCHITECTURE.md) | Components, data flow, scopes, and provider model | +| [Authority deployment modes](AUTHORITY-DEPLOYMENT.md) | Embedded and separate authority topologies, decision criteria, and origin boundaries | | [System diagrams](DIAGRAMS.md) | Trust boundaries, sequences, keys, scopes, and providers | | [Storage providers](STORAGE-PROVIDERS.md) | Provider abstraction, support levels, and conformance | | [Security](SECURITY.md) | Threat model, keys, encryption, revocation, and XSS boundary | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 7518921..f52e7bf 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -22,6 +22,8 @@ It does not hide: - request timing and frequency - predictable scope names unless the deployment makes them opaque - ciphertext and metadata from the storage provider or an authorised broker +- decoded document values from the trusted authority while it executes writes, + maintenance, or an optional bounded read bundle ## Key hierarchy @@ -145,6 +147,17 @@ buckets remain private. This keeps session revocation effective for future object retrieval and avoids treating ciphertext exposure as an access-control boundary. +Version 3.1 authorities may explicitly enable an authority-assembled bundle +containing decoded HEAD and immutable cache values over HTTPS. Bundle responses are +`no-store`, require the same explicit scope read grant, and are limited to four +objects and 4 MiB decoded. The individual TDB1 object path remains available +and is used automatically when the bundle is unavailable or rejected. + +The authority already holds the deployment master key for writes, index +maintenance, key rotation, and migration. A deployment that does not trust the +authority runtime with plaintext is outside the current ThimbleDB threat +model. + ## Secret handling Never commit: diff --git a/docs/STUDIO.md b/docs/STUDIO.md index 8c9685f..8184e10 100644 --- a/docs/STUDIO.md +++ b/docs/STUDIO.md @@ -197,6 +197,10 @@ Studio displays each configured index as: - `missing` - `mismatch` +The index table also shows explicitly declared covering fields. Studio queries +continue returning full documents; application code opts into covered +projections through typed `.select(...)`. + Applying configured indexes is an explicit maintenance operation. It may add, remove, or redefine index pages while preserving and verifying every stored record. Ordinary writes and other maintenance paths refuse index-set drift. diff --git a/docs/TRADEOFFS.md b/docs/TRADEOFFS.md index 09dc630..0b0cb7f 100644 --- a/docs/TRADEOFFS.md +++ b/docs/TRADEOFFS.md @@ -14,6 +14,10 @@ Workers. - Real R2 conditional writes, external sessions, retained deletion, and snapshot migration work on the tested Cloudflare reference deployment. +- Automated browser tests verify that an enabled bounded read bundle reduces + an eligible cold trie point read from four browser requests to one. +- Unit tests verify that an explicit covered projection can avoid loading the + full snapshot page and that uncovered fields fall back to full documents. ## Not established by the published evidence @@ -23,6 +27,8 @@ - Safe key rotation at useful scale. - Reliable production behaviour under multi-region write contention. - A meaningful reduction in coding-agent database mistakes. +- A production latency improvement from read bundles; updated live + multi-region measurements have not yet been published. ## Costs introduced by this design diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 62a814a..85595c8 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -71,6 +71,23 @@ authority-managed metadata and maintenance endpoints. This is a major release because bounded query behavior now fails closed when legacy collections do not yet contain authenticated size metadata. +## Version 3.1 + +Version 3.1 adds compatible read and index optimizations: + +- authorities may explicitly advertise a bounded point-read bundle endpoint +- clients use one browser request on eligible cold point reads +- clients fall back to individual object reads when the capability is absent + or bounded limits reject the bundle +- secondary index definitions may declare up to eight covering fields +- typed `.select(...)` projections can use those fields without loading full + documents + +The TDB1 envelope and collection HEAD formats are unchanged. Index pages gain +optional definition and projection fields that older readers ignore. Upgrade +every writing authority before enabling covering fields, then rebuild the +affected indexes while writes are quiescent. + ## Object protocol version `TDB1` is stored in every object envelope. Protocol compatibility is separate diff --git a/package-lock.json b/package-lock.json index a571652..3c755d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "thimbledb", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "thimbledb", - "version": "3.0.0", + "version": "3.1.0", "license": "Apache-2.0", "dependencies": { "jose": "^6.1.0" diff --git a/package.json b/package.json index df2e221..730d998 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thimbledb", - "version": "3.0.0", + "version": "3.1.0", "description": "Browser-oriented encrypted object-storage database with Node and Cloudflare authorities.", "private": false, "license": "Apache-2.0", @@ -57,6 +57,7 @@ "docs/USE-CASES.md", "docs/use-cases", "docs/ADAPTIVE-LAYOUTS.md", + "docs/AUTHORITY-DEPLOYMENT.md", "docs/AUTHENTICATION.md", "docs/DELETION-RETENTION.md", "docs/PUBLIC-API.md", diff --git a/scripts/e2e-server.ts b/scripts/e2e-server.ts index edc9b22..31d7076 100644 --- a/scripts/e2e-server.ts +++ b/scripts/e2e-server.ts @@ -97,6 +97,7 @@ for (const name of [ "THIMBLE_DISABLE_IP_RATE_LIMIT", "THIMBLE_MASTER_KEY", "THIMBLE_STUDIO", + "THIMBLE_READ_BUNDLES", "THIMBLE_STUDIO_ORIGIN", "THIMBLE_TRUSTED_PROXY_IPS", ]) { @@ -115,11 +116,12 @@ Object.assign(childEnvironment, { THIMBLE_COLLECTION_LAYOUTS: "customers=snapshot", THIMBLE_COLLECTIONS: "products,customers,orders", THIMBLE_COLLECTION_INDEXES: - '{"products":[{"name":"by-sku","fields":["sku"],"mode":"equality"},{"name":"by-price","fields":["priceCents"],"mode":"range"}]}', + '{"products":[{"name":"by-sku","fields":["sku"],"mode":"equality","include":["name","priceCents"]},{"name":"by-price","fields":["priceCents"],"mode":"range","include":["sku","name"]}]}', THIMBLE_DELETE_RETENTION_DAYS: "30", THIMBLE_DELETE_GRACE_DAYS: "7", THIMBLE_MAINTENANCE_MODE: "false", THIMBLE_STUDIO: "true", + THIMBLE_READ_BUNDLES: "true", THIMBLE_STUDIO_ORIGIN: "http://127.0.0.1:8787", THIMBLE_AUTH_RATE_LIMIT: "100", THIMBLE_AUTH_RATE_WINDOW_MS: "60000", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 025c71e..4b1e096 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -188,6 +188,43 @@ try { import { LocalObjectStore } from "thimbledb/providers/local"; import { createArchiveManifest } from "thimbledb/migration"; + type Note = { + id: string; + title: string; + lastModified: number; + }; + + const notes = defineCollection("notes", { + indexes: [ + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["lastModified"] }, + ), + ], + }); + const noteSummarySchema = { + parse(value: unknown) { + return value as Pick< + Note, + "id" | "title" | "lastModified" + >; + }, + }; + + async function projected() { + const client = await createThimbleClient(); + return client + .collection(notes) + .where((note) => note.title.eq("example")) + .select( + ["title", "lastModified"], + noteSummarySchema, + ) + .get(); + } + void [ ThimbleClient, createThimbleClient, @@ -196,6 +233,7 @@ try { AuthService, LocalObjectStore, createArchiveManifest, + projected, ]; `, ); diff --git a/site/src/data/docs.ts b/site/src/data/docs.ts index 413312f..8e40168 100644 --- a/site/src/data/docs.ts +++ b/site/src/data/docs.ts @@ -187,6 +187,15 @@ export const docs: DocMeta[] = [ order: 10, featured: true, }, + { + id: "authority-deployment", + title: "Authority deployment modes", + description: + "Choose an embedded or separately deployed authority and preserve the browser origin, secret, and operations boundaries.", + group: "Understand", + order: 15, + featured: true, + }, { id: "diagrams", title: "System diagrams", @@ -258,7 +267,7 @@ export const docs: DocMeta[] = [ id: "queries-indexes", title: "Queries and secondary indexes", description: - "Define typed collections, bounded predicates, deterministic query plans, and explicit immutable indexes.", + "Define typed collections, bounded predicates, deterministic query plans, explicit indexes, and covering projections.", group: "Understand", order: 75, featured: true, diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index b110da0..b528f51 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -79,7 +79,7 @@ const websiteSchema = {
  • Apache-2.0
  • -
  • 16.3 KB gzip browser build
  • +
  • 18.7 KB gzip browser build
  • 2-package base install
  • Node.js 22+
@@ -220,11 +220,12 @@ const websiteSchema = { .where((note) => note.title.eq("First note")) .orderBy((note) => note.lastModified.desc()) .take(25) - .get(); -

- Declared indexes narrow candidates. The client checks the complete - predicate and falls back to a bounded scan when needed. -

+ .select(["title", "lastModified"], noteCardSchema) + .get(); +

+ Declared indexes narrow candidates. Explicit covering fields can + satisfy a typed projection without loading complete documents. +

diff --git a/site/src/pages/llms.txt.ts b/site/src/pages/llms.txt.ts index 653fced..1f60a61 100644 --- a/site/src/pages/llms.txt.ts +++ b/site/src/pages/llms.txt.ts @@ -33,11 +33,12 @@ scope and one collection. ## Architecture and security - [Architecture](${site.url}/docs/architecture/): Browser cache, authority, object storage, and scopes. +- [Authority deployment modes](${site.url}/docs/authority-deployment/): Embedded and separate authority services, decision criteria, and same-origin requirements. - [Security](${site.url}/security/): Threat model, encryption, key handling, and browser boundaries. - [Authentication](${site.url}/docs/authentication/): External OIDC identities and revocable sessions. - [Machine and service access](${site.url}/docs/service-access/): Entra roles, service principals, live viewers, and why there is no global admin key. - [Object protocol](${site.url}/docs/protocol/): TDB1 envelopes, snapshots, tries, and conditional writes. -- [Queries and indexes](${site.url}/docs/queries-indexes/): Typed predicates and developer-declared secondary indexes. +- [Queries and indexes](${site.url}/docs/queries-indexes/): Typed predicates, developer-declared secondary indexes, and explicit covering projections. - [Deletion and retention](${site.url}/docs/deletion-retention/): Tombstones, restore windows, and physical collection. - [Logical migration](${site.url}/docs/migration/): Portable archives and database adapters. diff --git a/site/tests/site.spec.ts b/site/tests/site.spec.ts index 03eff5a..e5fd4a8 100644 --- a/site/tests/site.spec.ts +++ b/site/tests/site.spec.ts @@ -72,6 +72,27 @@ test("repository documentation renders with rewritten internal links", async ({ true, ); await assertNoHorizontalOverflow(page); + + await page.goto("/docs/authority-deployment/"); + await expect( + page.getByRole("heading", { + level: 1, + name: "Authority deployment modes", + }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { + level: 2, + name: "Embedded authority", + }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { + level: 2, + name: "Separate authority service", + }), + ).toBeVisible(); + await assertNoHorizontalOverflow(page); }); test("documentation search returns relevant repository pages", async ({ diff --git a/src/browser/client.ts b/src/browser/client.ts index 0a6aa63..2f4430b 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -35,15 +35,20 @@ import { import { evaluateThimbleQuery, pointReadId, + queryFieldNames, validateThimbleQuery, type ThimbleQuery, type ThimbleQueryResult, } from "../query.js"; import { idsFromSecondaryIndex, + documentsFromCoveringIndex, + encodeSecondaryIndexPage, + MAX_SECONDARY_INDEX_PAGE_BYTES, planSecondaryIndex, secondaryIndexDefinitionsEqual, secondaryIndexPageFromJson, + validateProjectionFields, type CollectionIndexConfiguration, type SecondaryIndexPage, type SecondaryIndexReference, @@ -56,6 +61,7 @@ import { } from "./cache.js"; import type { JsonObjectReader, + PointReadBundleReader, RemoteJsonObject, } from "./remote-reader.js"; import { HttpObjectReadError } from "./remote-reader.js"; @@ -69,6 +75,9 @@ import { export type ThimbleClientMetrics = { remoteReads: number; remoteBytes: number; + bundleReads: number; + bundleBytes: number; + bundleFallbacks: number; notModified: number; missing: number; offlineFallbacks: number; @@ -82,6 +91,9 @@ const MAX_BOUNDED_DECODED_BYTES = 16 * 1024 * 1024; export class ThimbleClient { private remoteReads = 0; private remoteBytes = 0; + private bundleReads = 0; + private bundleBytes = 0; + private bundleFallbacks = 0; private notModified = 0; private missing = 0; private offlineFallbacks = 0; @@ -103,6 +115,7 @@ export class ThimbleClient { constructor( private readonly options: { reader: JsonObjectReader; + bundleReader?: PointReadBundleReader; cache: TieredObjectCache; headTtlMs: number; writeBaseUrl?: string; @@ -120,10 +133,13 @@ export class ThimbleClient { collectionIndexes?: CollectionIndexConfiguration; layoutGeneration?: string; configurationUrl?: string; + configurationCheckedAt?: number; layoutCheckTtlMs?: number; onLayoutChange?: () => void; }, ) { + this.layoutCheckedAt = + options.configurationCheckedAt ?? 0; this.channel = typeof BroadcastChannel === "undefined" ? null @@ -203,17 +219,21 @@ export class ThimbleClient { async queryDocuments( collection: string, query: ThimbleQuery, + projectionFields?: string[], ): Promise> { validateThimbleQuery(query); + if (projectionFields) { + validateProjectionFields(projectionFields); + } const pointId = pointReadId(query); if (pointId) { const document = await this.get(collection, pointId); - return { + return projectQueryResult({ documents: document ? [document as unknown as T] : [], plan: "point", indexName: null, scannedDocuments: document ? 1 : 0, - }; + }, projectionFields); } const definitions = this.options.collectionIndexes?.[collection] ?? []; @@ -228,6 +248,19 @@ export class ThimbleClient { : await this.readHead(collection, generation); const reference = head.indexes?.[indexPlan.definition.name]; if (reference) { + if ( + typeof reference.decodedBytes !== "number" || + reference.decodedBytes > + MAX_SECONDARY_INDEX_PAGE_BYTES + ) { + return projectQueryResult(evaluateThimbleQuery( + (await this.scanBounded( + collection, + query.maxScanDocuments ?? 1_000, + )) as unknown as T[], + query, + ), projectionFields); + } const page = await this.readSecondaryIndexPage( layout, collection, @@ -235,19 +268,27 @@ export class ThimbleClient { reference.hash, generation, ); + if ( + encodeSecondaryIndexPage(page).byteLength !== + reference.decodedBytes + ) { + throw new Error( + `Secondary index ${indexPlan.definition.name} size does not match its collection head`, + ); + } if ( !secondaryIndexDefinitionsEqual( page.definition, indexPlan.definition, ) ) { - return evaluateThimbleQuery( + return projectQueryResult(evaluateThimbleQuery( (await this.scanBounded( collection, query.maxScanDocuments ?? 1_000, )) as unknown as T[], query, - ); + ), projectionFields); } if (reference.entries !== page.entries.length) { throw new Error( @@ -261,33 +302,45 @@ export class ThimbleClient { `Secondary index ${indexPlan.definition.name} matched ${ids.length} documents, above the configured maximum of ${maximum}`, ); } - const documents = ( - await Promise.all( - ids.map((id) => this.get(collection, id)), - ) - ).filter( - (document): document is JsonDocument => - document !== null, - ) as unknown as T[]; + const coveredDocuments = projectionFields + ? documentsFromCoveringIndex( + page, + indexPlan, + [ + ...queryFieldNames(query), + ...projectionFields, + ], + ) + : null; + const documents = coveredDocuments + ? (coveredDocuments as unknown as T[]) + : (( + await Promise.all( + ids.map((id) => this.get(collection, id)), + ) + ).filter( + (document): document is JsonDocument => + document !== null, + ) as unknown as T[]); const result = evaluateThimbleQuery(documents, { ...query, maxScanDocuments: maximum, }); - return { + return projectQueryResult({ ...result, plan: "index", indexName: indexPlan.definition.name, scannedDocuments: ids.length, - }; + }, projectionFields); } } - return evaluateThimbleQuery( + return projectQueryResult(evaluateThimbleQuery( (await this.scanBounded( collection, query.maxScanDocuments ?? 1_000, )) as unknown as T[], query, - ); + ), projectionFields); } explainQuery( @@ -328,6 +381,14 @@ export class ThimbleClient { ): Promise { await this.ensureLayoutCurrent(false); const generation = this.currentGeneration(); + const bundled = await this.readPointBundleIfCold( + collection, + id, + generation, + ); + if (bundled.used) { + return bundled.document; + } if (this.layoutFor(collection) === "snapshot") { return this.getSnapshot(collection, id, generation); } @@ -674,11 +735,13 @@ export class ThimbleClient { const head = bundle.objects.find((object) => object.key.endsWith("/HEAD.json"), ); - const bundleLayout = bundle.objects.some((object) => - object.key.startsWith("content-snapshot/"), - ) - ? "snapshot" - : "trie"; + const bundleLayout = + bundle.layout ?? + (bundle.objects.some((object) => + object.key.startsWith("content-snapshot/"), + ) + ? "snapshot" + : "trie"); if (bundleLayout !== this.layoutFor(bundle.collection)) { await this.handleLayoutChange(); throw new Error("Collection layout changed; reload required"); @@ -748,6 +811,9 @@ export class ThimbleClient { resetMetrics(): void { this.remoteReads = 0; this.remoteBytes = 0; + this.bundleReads = 0; + this.bundleBytes = 0; + this.bundleFallbacks = 0; this.notModified = 0; this.missing = 0; this.offlineFallbacks = 0; @@ -758,6 +824,9 @@ export class ThimbleClient { return { remoteReads: this.remoteReads, remoteBytes: this.remoteBytes, + bundleReads: this.bundleReads, + bundleBytes: this.bundleBytes, + bundleFallbacks: this.bundleFallbacks, notModified: this.notModified, missing: this.missing, offlineFallbacks: this.offlineFallbacks, @@ -1122,6 +1191,69 @@ export class ThimbleClient { return result; } + private async readPointBundleIfCold( + collection: string, + id: string, + generation: number, + ): Promise<{ + used: boolean; + document: JsonDocument | null; + }> { + if (!this.options.bundleReader) { + return { used: false, document: null }; + } + const layout = this.layoutFor(collection); + const headKey = + layout === "snapshot" + ? snapshotHeadKey(collection) + : trieHeadKey(collection); + const cachedHead = await this.options.cache.get(headKey); + this.assertGeneration(generation); + if (cachedHead) { + return { used: false, document: null }; + } + this.remoteReads += 1; + this.bundleReads += 1; + let result; + try { + result = await this.options.bundleReader.get( + collection, + id, + ); + } catch (error) { + if ( + error instanceof HttpObjectReadError && + (error.status === 401 || error.status === 403) + ) { + await this.handleAuthorizationFailure(error.status); + } + throw error; + } + this.assertGeneration(generation); + if (result.status === "fallback") { + this.bundleFallbacks += 1; + return { used: false, document: null }; + } + this.remoteBytes += result.bytes; + this.bundleBytes += result.bytes; + if ( + result.bundle.layout && + result.bundle.layout !== layout + ) { + await this.handleLayoutChange(); + throw new Error("Collection layout changed; reload required"); + } + await this.applyBundle( + result.bundle, + false, + generation, + ); + return { + used: true, + document: result.bundle.document, + }; + } + private async handleAuthorizationFailure( status: number, ): Promise { @@ -1375,6 +1507,28 @@ function cacheEntryFromRemote( }; } +function projectQueryResult( + result: ThimbleQueryResult, + projectionFields?: string[], +): ThimbleQueryResult { + if (!projectionFields) { + return result; + } + return { + ...result, + documents: result.documents.map((document) => { + const projection: JsonDocument = { id: document.id }; + for (const field of projectionFields) { + const value = (document as Record)[field]; + if (value !== undefined) { + projection[field] = structuredClone(value); + } + } + return projection as unknown as T; + }), + }; +} + async function hashId(id: string): Promise { const digest = await crypto.subtle.digest( "SHA-256", diff --git a/src/browser/collection.ts b/src/browser/collection.ts index edb0c20..967ec2c 100644 --- a/src/browser/collection.ts +++ b/src/browser/collection.ts @@ -15,6 +15,7 @@ import { } from "../shared-utils.js"; import { validateIndexConfiguration, + validateProjectionFields, type CollectionIndexConfiguration, type SecondaryIndexDefinition, } from "../secondary-index.js"; @@ -29,6 +30,11 @@ export type CollectionDefinition = { indexes?: SecondaryIndexDefinition[]; }; +export type ProjectedDocument< + T extends { id: string }, + K extends Extract, +> = Pick; + export type QueryFieldExpression< T extends { id: string }, K extends Extract, @@ -74,6 +80,7 @@ export interface CollectionClient { queryDocuments?( collection: string, query: ThimbleQuery, + projectionFields?: string[], ): Promise>; explainQuery?( collection: string, @@ -206,6 +213,7 @@ export class ThimbleCollection { ), }; } + const pointId = pointReadId(query); if (pointId) { const document = await this.get(pointId); @@ -219,6 +227,47 @@ export class ThimbleCollection { return evaluateThimbleQuery(await this.scan(), query); } + async queryProjection< + K extends Exclude, "id">, + >( + query: ThimbleQuery, + fields: K[], + schema: ThimbleSchema>, + ): Promise>> { + validateProjectionFields(fields); + if (this.client.queryDocuments) { + const result = await this.client.queryDocuments( + this.definition.name, + query, + fields, + ); + return { + ...result, + documents: result.documents.map((document) => + parseProjection( + this.definition.name, + schema, + document, + ), + ), + }; + } + const result = evaluateThimbleQuery( + await this.scan(), + query, + ); + return { + ...result, + documents: result.documents.map((document) => + parseProjection( + this.definition.name, + schema, + projectDocument(document, fields), + ), + ), + }; + } + where( predicate: (fields: QueryFields) => QueryExpression, ): ThimbleQueryBuilder { @@ -360,6 +409,21 @@ export class ThimbleQueryBuilder { return this; } + select< + K extends Exclude, "id">, + >( + fields: K[], + schema: ThimbleSchema>, + ): ThimbleProjectionQueryBuilder { + validateProjectionFields(fields); + return new ThimbleProjectionQueryBuilder( + this.collection, + this.queryValue, + fields, + schema, + ); + } + get(): Promise> { return this.collection.query(this.queryValue); } @@ -373,6 +437,44 @@ export class ThimbleQueryBuilder { } } +export class ThimbleProjectionQueryBuilder< + T extends { id: string }, + K extends Exclude, "id">, +> { + constructor( + private readonly collection: ThimbleCollection, + private readonly query: ThimbleQuery, + private readonly fields: K[], + private readonly schema: ThimbleSchema< + ProjectedDocument + >, + ) {} + + get(): Promise< + ThimbleQueryResult> + > { + return this.collection.queryProjection( + this.query, + this.fields, + this.schema, + ); + } + + explain(): QueryPlan { + return this.collection.explain(this.query); + } + + toJSON(): { + query: ThimbleQuery; + select: K[]; + } { + return { + query: structuredClone(this.query), + select: [...this.fields], + }; + } +} + function queryFields(): QueryFields { return new Proxy( {}, @@ -424,3 +526,38 @@ type QueryExpressionOperator = ? Operator : never : never; + +function parseProjection< + T extends { id: string }, + K extends Extract, +>( + collection: string, + schema: ThimbleSchema>, + value: unknown, +): ProjectedDocument { + try { + return schema.parse(value); + } catch (error) { + throw new Error( + `Projection validation failed in collection ${collection}`, + { cause: error }, + ); + } +} + +function projectDocument< + T extends { id: string }, + K extends Exclude, "id">, +>( + document: T, + fields: K[], +): ProjectedDocument { + const projection = { id: document.id } as + ProjectedDocument; + for (const field of fields) { + if (document[field] !== undefined) { + projection[field] = structuredClone(document[field]); + } + } + return projection; +} diff --git a/src/browser/connect.ts b/src/browser/connect.ts index b489a87..e8a5e1a 100644 --- a/src/browser/connect.ts +++ b/src/browser/connect.ts @@ -23,6 +23,7 @@ import { ThimbleClient } from "./client.js"; import { EnvelopeJsonObjectReader, HttpByteObjectReader, + HttpPointReadBundleReader, ScopedJsonObjectReader, } from "./remote-reader.js"; @@ -30,6 +31,7 @@ export type ThimbleAuthorityConfig = { name: string; provider: "local" | "azure" | "s3" | "r2"; readBaseUrl: string; + readBundleBaseUrl?: string; headTtlMs: number; cachePolicy: CachePolicy; collectionLayouts: Record; @@ -139,6 +141,12 @@ export async function createThimbleConnection( config.readBaseUrl, configurationUrl, ).toString(); + const readBundleBaseUrl = config.readBundleBaseUrl + ? new URL( + config.readBundleBaseUrl, + configurationUrl, + ).toString() + : null; const namespace = [ config.provider, new URL(readBaseUrl).origin, @@ -197,6 +205,16 @@ export async function createThimbleConnection( ); const client = new ThimbleClient({ reader, + ...(readBundleBaseUrl + ? { + bundleReader: new HttpPointReadBundleReader( + readBundleBaseUrl, + config.scope.id, + fetchImplementation, + configurationUrl.toString(), + ), + } + : {}), cache, headTtlMs: config.headTtlMs, csrfToken: config.csrfToken, @@ -218,6 +236,7 @@ export async function createThimbleConnection( collectionIndexes: config.collectionIndexes, layoutGeneration: config.layoutGeneration, configurationUrl: configurationUrl.toString(), + configurationCheckedAt: Date.now(), layoutCheckTtlMs: options.layoutCheckTtlMs ?? 1_000, onLayoutChange: options.onLayoutChange ?? @@ -299,6 +318,9 @@ function validateConfig(value: unknown): ThimbleAuthorityConfig { typeof value.name !== "string" || !isProvider(value.provider) || typeof value.readBaseUrl !== "string" || + (value.readBundleBaseUrl !== undefined && + (typeof value.readBundleBaseUrl !== "string" || + value.readBundleBaseUrl.length === 0)) || typeof value.headTtlMs !== "number" || !Number.isFinite(value.headTtlMs) || value.headTtlMs < 0 || diff --git a/src/browser/main.ts b/src/browser/main.ts index 2512fbd..e79c1f0 100644 --- a/src/browser/main.ts +++ b/src/browser/main.ts @@ -692,6 +692,9 @@ function renderMetrics(): void { ["Cache misses", metrics.cache.misses], ["Remote reads", metrics.remoteReads], ["Remote bytes", formatBytes(metrics.remoteBytes)], + ["Read bundles", metrics.bundleReads], + ["Bundle bytes", formatBytes(metrics.bundleBytes)], + ["Bundle fallbacks", metrics.bundleFallbacks], ["HEAD 304s", metrics.notModified], ["Offline fallbacks", metrics.offlineFallbacks], ["Memory entries", metrics.cache.memoryEntries], diff --git a/src/browser/remote-reader.ts b/src/browser/remote-reader.ts index bae1bff..9d8eb9b 100644 --- a/src/browser/remote-reader.ts +++ b/src/browser/remote-reader.ts @@ -4,6 +4,7 @@ import { type EnvelopeKeyResolver, } from "../envelope.js"; import { scopeStoragePrefix } from "../trie-protocol.js"; +import type { TrieReadBundle } from "../trie-protocol.js"; export type RemoteJsonObject = | { @@ -54,6 +55,23 @@ export interface ByteObjectReader { ): Promise; } +export type RemoteReadBundle = + | { + status: "found"; + bundle: TrieReadBundle; + bytes: number; + } + | { + status: "fallback"; + }; + +export interface PointReadBundleReader { + get( + collection: string, + id: string, + ): Promise; +} + export class HttpObjectReadError extends Error { constructor( readonly status: number, @@ -125,6 +143,66 @@ export class HttpByteObjectReader implements ByteObjectReader { } } +export class HttpPointReadBundleReader +implements PointReadBundleReader { + constructor( + private readonly baseUrl: string, + private readonly scopeId: string, + private readonly fetchImplementation: typeof fetch = fetch, + private readonly origin = globalThis.location?.href ?? + "http://127.0.0.1/", + ) {} + + async get( + collection: string, + id: string, + ): Promise { + const url = new URL(this.baseUrl, this.origin); + url.pathname = [ + url.pathname.replace(/\/+$/, ""), + encodeURIComponent(this.scopeId), + encodeURIComponent(collection), + encodeURIComponent(id), + ].join("/"); + const response = await this.fetchImplementation.call( + globalThis, + url, + { + credentials: "same-origin", + cache: "no-store", + }, + ); + if ( + response.status === 404 || + response.status === 409 || + response.status === 413 + ) { + return { status: "fallback" }; + } + if (!response.ok) { + throw new HttpObjectReadError( + response.status, + `bundle:${collection}/${id}`, + ); + } + const body = await response.text(); + const bundle = readBundleFromJson(JSON.parse(body) as unknown); + if ( + bundle.collection !== collection || + bundle.id !== id + ) { + throw new Error( + "Read bundle does not match the requested document", + ); + } + return { + status: "found", + bundle, + bytes: new TextEncoder().encode(body).byteLength, + }; + } +} + export class EnvelopeJsonObjectReader implements JsonObjectReader { constructor( private readonly delegate: ByteObjectReader, @@ -230,3 +308,42 @@ export function objectUrl( url.search = query; return url.toString(); } + +function readBundleFromJson(value: unknown): TrieReadBundle { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + !("collection" in value) || + typeof value.collection !== "string" || + !("id" in value) || + typeof value.id !== "string" || + !("revision" in value) || + typeof value.revision !== "number" || + !Number.isInteger(value.revision) || + value.revision < 0 || + !("objects" in value) || + !Array.isArray(value.objects) || + !value.objects.every( + (object) => + typeof object === "object" && + object !== null && + !Array.isArray(object) && + "key" in object && + typeof object.key === "string" && + "etag" in object && + typeof object.etag === "string" && + "value" in object, + ) || + !("document" in value) || + (value.document !== null && + (typeof value.document !== "object" || + Array.isArray(value.document))) || + ("layout" in value && + value.layout !== "trie" && + value.layout !== "snapshot") + ) { + throw new Error("Read bundle response is malformed"); + } + return value as TrieReadBundle; +} diff --git a/src/cloudflare-worker.ts b/src/cloudflare-worker.ts index 981ccbe..dfb36da 100644 --- a/src/cloudflare-worker.ts +++ b/src/cloudflare-worker.ts @@ -34,6 +34,7 @@ import type { JsonValue, ObjectStore, } from "./core.js"; +import { BoundedReadError } from "./core.js"; import { ContentAddressedTrieEngine } from "./engines/content-trie.js"; import { ImmutableSnapshotEngine } from "./engines/immutable-snapshot.js"; import { EnvelopeObjectStore } from "./envelope-store.js"; @@ -56,6 +57,7 @@ import { import type { CollectionLayout } from "./snapshot-protocol.js"; import { parseIndexConfiguration, + SecondaryIndexLimitError, validateIndexConfiguration, type CollectionIndexConfiguration, } from "./secondary-index.js"; @@ -70,6 +72,7 @@ import { studioDeletedDocuments, studioScopes, } from "./studio-api.js"; +import { readPointBundle } from "./read-bundle.js"; type RateLimitBinding = { limit(options: { key: string }): Promise<{ success: boolean }>; @@ -92,6 +95,7 @@ export type CloudflareAuthorityEnv = { THIMBLE_DELETE_GRACE_DAYS?: string; THIMBLE_MAINTENANCE_MODE?: string; THIMBLE_STUDIO?: string; + THIMBLE_READ_BUNDLES?: string; THIMBLE_STUDIO_ORIGIN?: string; THIMBLE_COLLECTIONS?: string; ENTRA_TENANT_ID?: string; @@ -137,6 +141,7 @@ type Runtime = { layoutGeneration: string; maintenanceMode: boolean; studioEnabled: boolean; + readBundlesEnabled: boolean; studioOrigin: string | null; oidcProviders: string[]; allowedOrigin: string; @@ -148,6 +153,7 @@ export type CloudflareAuthorityOptions = { collectionIndexes?: CollectionIndexConfiguration; collections?: string[]; studio?: boolean; + readBundles?: boolean; studioOrigin?: string; }; @@ -166,26 +172,45 @@ export function createCloudflareAuthority( ); return await route(await runtimePromise, request, env); } catch (error) { - if (!(error instanceof AuthError && error.status < 500)) { - console.error(error); + const handledError = + error instanceof SecondaryIndexLimitError + ? new AuthError( + 413, + "secondary_index_too_large", + error.message, + ) + : error; + if ( + !( + handledError instanceof AuthError && + handledError.status < 500 + ) + ) { + console.error(handledError); } - const status = error instanceof AuthError ? error.status : 500; + const status = + handledError instanceof AuthError + ? handledError.status + : 500; const headers = new Headers(); - if (error instanceof AuthError && error.retryAfterSeconds) { + if ( + handledError instanceof AuthError && + handledError.retryAfterSeconds + ) { headers.set( "retry-after", - String(error.retryAfterSeconds), + String(handledError.retryAfterSeconds), ); } return json( { error: - error instanceof AuthError - ? error.code + handledError instanceof AuthError + ? handledError.code : "internal_error", message: - error instanceof AuthError - ? error.message + handledError instanceof AuthError + ? handledError.message : "Request failed", }, status, @@ -620,6 +645,9 @@ async function route( name: "ThimbleDB", provider: "r2", readBaseUrl: "/api/objects", + ...(runtime.readBundlesEnabled + ? { readBundleBaseUrl: "/api/read-bundles" } + : {}), headTtlMs: runtime.headTtlMs, cachePolicy: "content", collectionLayouts: runtime.collectionLayouts, @@ -676,6 +704,50 @@ async function route( }); } + const readBundleRoute = + /^\/api\/read-bundles\/([^/]+)\/([^/]+)\/([^/]+)$/.exec( + url.pathname, + ); + if ( + request.method === "GET" && + runtime.readBundlesEnabled && + readBundleRoute?.[1] && + readBundleRoute[2] && + readBundleRoute[3] + ) { + requireAuthenticated(authenticated); + const scopeId = decodePathSegment(readBundleRoute[1]); + requireGrant(authenticated.session.grants, scopeId, "read"); + const collection = decodePathSegment(readBundleRoute[2]); + const id = decodePathSegment(readBundleRoute[3]); + const scope = await runtime.scope(scopeId); + try { + const bundle = await readPointBundle( + engineFor(runtime, scope, collection), + collection, + id, + ); + return json( + bundle, + 200, + new Headers({ + "x-thimble-bundle-objects": String( + bundle.objects.length, + ), + }), + ); + } catch (error) { + if (error instanceof BoundedReadError) { + throw new AuthError( + 413, + "read_bundle_unavailable", + error.message, + ); + } + throw error; + } + } + if ( request.method === "GET" && url.pathname.startsWith("/api/objects/") @@ -1024,6 +1096,9 @@ async function createRuntime( : configuredCollectionIndexes(env); const studioEnabled = options.studio ?? env.THIMBLE_STUDIO === "true"; + const readBundlesEnabled = + options.readBundles ?? + env.THIMBLE_READ_BUNDLES === "true"; const collections = studioEnabled ? studioCollectionCatalog({ collections: @@ -1072,6 +1147,7 @@ async function createRuntime( layoutGeneration, maintenanceMode: env.THIMBLE_MAINTENANCE_MODE === "true", studioEnabled, + readBundlesEnabled, studioOrigin: options.studioOrigin ?? env.THIMBLE_STUDIO_ORIGIN ?? diff --git a/src/engines/content-trie.ts b/src/engines/content-trie.ts index c3b0671..25f795b 100644 --- a/src/engines/content-trie.ts +++ b/src/engines/content-trie.ts @@ -30,12 +30,14 @@ import { type TrieLeafMetadata, type TrieNode, type TrieReadBundle, + type ReadBundleLimits, type TrieRootNode, type TrieStoredDocument, type TrieTombstone, } from "../trie-protocol.js"; import { buildSecondaryIndexPage, + encodeSecondaryIndexPage, secondaryIndexDefinitionsEqual, secondaryIndexPageFromJson, updateSecondaryIndexPage, @@ -58,6 +60,13 @@ type TrieUpdate = { second: string; }; +type PreparedSecondaryIndex = { + key: string; + bytes: Uint8Array; + name: string; + reference: SecondaryIndexReference; +}; + export class ContentAddressedTrieEngine implements DatabaseEngine { readonly name = "content-addressed-trie"; private casRetries = 0; @@ -444,10 +453,11 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { ); } const normalized = validateName(collection, "Collection"); - const indexes = await this.writeFreshIndexes( + const preparedIndexes = await this.prepareFreshIndexes( normalized, documents, ); + const indexes = await this.commitIndexes(preparedIndexes); for (let attempt = 0; attempt < this.maxRetries; attempt += 1) { const head = await this.loadHead(normalized); const nextHead: TrieHead = { @@ -532,6 +542,11 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { ) { return false; } + const preparedIndexes = await this.prepareIndexes( + normalized, + head.state, + collapsedChanges, + ); const root = head.state.rootHash === null ? this.emptyRoot() @@ -631,10 +646,8 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { Object.keys(nextRoot.children).length === 0 ? null : await this.writeNode(normalized, nextRoot); - const indexes = await this.writeIndexes( - normalized, - head.state, - collapsedChanges, + const indexes = await this.commitIndexes( + preparedIndexes, ); const nextHead: TrieHead = { revision: head.state.revision + 1, @@ -742,17 +755,25 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { async readBundle( collection: string, id: string, + limits?: ReadBundleLimits, ): Promise { const normalized = validateName(collection, "Collection"); const head = await this.loadHead(normalized); - const objects = []; + const objects: TrieReadBundle["objects"] = []; + let decodedBytes = 0; if (head.object !== null) { - objects.push({ + decodedBytes = addBundleObject( + objects, + decodedBytes, + { key: trieHeadKey(normalized), etag: head.object.etag, value: head.state as unknown as JsonValue, - }); + }, + head.object.bytes.byteLength, + limits, + ); } if (head.state.rootHash === null) { @@ -762,6 +783,7 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { revision: head.state.revision, document: null, objects, + layout: "trie", }; } @@ -771,11 +793,17 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { head.state.rootHash, "root", ); - objects.push({ - key: trieNodeKey(normalized, head.state.rootHash), - etag: root.object.etag, - value: root.value as unknown as JsonValue, - }); + decodedBytes = addBundleObject( + objects, + decodedBytes, + { + key: trieNodeKey(normalized, head.state.rootHash), + etag: root.object.etag, + value: root.value as unknown as JsonValue, + }, + root.object.bytes.byteLength, + limits, + ); const branchHash = root.value.children[first]; if (!branchHash) { return { @@ -784,6 +812,7 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { revision: head.state.revision, document: null, objects, + layout: "trie", }; } @@ -792,11 +821,17 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { branchHash, "branch", ); - objects.push({ - key: trieNodeKey(normalized, branchHash), - etag: branch.object.etag, - value: branch.value as unknown as JsonValue, - }); + decodedBytes = addBundleObject( + objects, + decodedBytes, + { + key: trieNodeKey(normalized, branchHash), + etag: branch.object.etag, + value: branch.value as unknown as JsonValue, + }, + branch.object.bytes.byteLength, + limits, + ); const leafHash = branch.value.children[second]; if (!leafHash) { return { @@ -805,22 +840,42 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { revision: head.state.revision, document: null, objects, + layout: "trie", }; } + if (limits) { + const metadata = branch.value.leafMetadata?.[second]; + if (!metadata) { + throw new BoundedReadError( + "Trie leaf size metadata is unavailable for a bounded read bundle", + ); + } + assertBundleCapacity( + objects.length + 1, + decodedBytes + metadata.decodedBytes, + limits, + ); + } const leaf = await this.loadNodeObject( normalized, leafHash, "leaf", ); - objects.push({ - key: trieNodeKey( - normalized, - leafHash, - ), - etag: leaf.object.etag, - value: leaf.value as unknown as JsonValue, - }); + addBundleObject( + objects, + decodedBytes, + { + key: trieNodeKey( + normalized, + leafHash, + ), + etag: leaf.object.etag, + value: leaf.value as unknown as JsonValue, + }, + leaf.object.bytes.byteLength, + limits, + ); return { collection: normalized, @@ -830,6 +885,7 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { ownValue(leaf.value.documents, id), ), objects, + layout: "trie", }; } @@ -845,19 +901,18 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { return { object, state: decodeJson(object.bytes) }; } - private async writeIndexes( + private async prepareIndexes( collection: string, head: TrieHead, changes: SecondaryIndexChange[], - ): Promise { + ): Promise { const definitions = this.indexConfiguration[collection] ?? []; if (definitions.length === 0) { - return {}; + return []; } let storedDocuments: TrieStoredDocument[] | undefined; - const references = - createDictionary(); + const prepared: PreparedSecondaryIndex[] = []; for (const definition of definitions) { const currentReference = head.indexes?.[definition.name]; let currentPage: SecondaryIndexPage | null = null; @@ -909,45 +964,66 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { definition, changes, ); - const bytes = encodeJson(page as unknown as JsonValue); + const bytes = encodeSecondaryIndexPage(page); const hash = await this.addressNode(bytes); - try { - await this.store.put( - trieIndexKey(collection, definition.name, hash), - bytes, - { ifNoneMatch: true }, - ); - } catch (error) { - if (!isPreconditionFailure(error)) { - throw error; - } - } - references[definition.name] = { - hash, - entries: page.entries.length, - decodedBytes: bytes.byteLength, - }; + prepared.push({ + key: trieIndexKey( + collection, + definition.name, + hash, + ), + bytes, + name: definition.name, + reference: { + hash, + entries: page.entries.length, + decodedBytes: bytes.byteLength, + }, + }); } - return references; + return prepared; } - private async writeFreshIndexes( + private async prepareFreshIndexes( collection: string, documents: TrieStoredDocument[], - ): Promise { - const references = - createDictionary(); + ): Promise { + const prepared: PreparedSecondaryIndex[] = []; for (const definition of this.indexConfiguration[collection] ?? []) { const page = buildSecondaryIndexPage( definition, documents, ); - const bytes = encodeJson(page as unknown as JsonValue); + const bytes = encodeSecondaryIndexPage(page); const hash = await this.addressNode(bytes); + prepared.push({ + key: trieIndexKey( + collection, + definition.name, + hash, + ), + bytes, + name: definition.name, + reference: { + hash, + entries: page.entries.length, + decodedBytes: bytes.byteLength, + }, + }); + } + return prepared; + } + + private async commitIndexes( + prepared: PreparedSecondaryIndex[], + ): Promise { + const references = + createDictionary(); + for (const index of prepared) { try { await this.store.put( - trieIndexKey(collection, definition.name, hash), - bytes, + index.key, + index.bytes, { ifNoneMatch: true }, ); } catch (error) { @@ -955,11 +1031,7 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { throw error; } } - references[definition.name] = { - hash, - entries: page.entries.length, - decodedBytes: bytes.byteLength, - }; + references[index.name] = index.reference; } return references; } @@ -1281,6 +1353,40 @@ export class ContentAddressedTrieEngine implements DatabaseEngine { } } +function addBundleObject( + objects: TrieReadBundle["objects"], + decodedBytes: number, + object: TrieReadBundle["objects"][number], + objectBytes: number, + limits?: ReadBundleLimits, +): number { + const nextBytes = decodedBytes + objectBytes; + if (limits) { + assertBundleCapacity( + objects.length + 1, + nextBytes, + limits, + ); + } + objects.push(object); + return nextBytes; +} + +function assertBundleCapacity( + objects: number, + decodedBytes: number, + limits: ReadBundleLimits, +): void { + if ( + objects > limits.maxObjects || + decodedBytes > limits.maxDecodedBytes + ) { + throw new BoundedReadError( + `Read bundle exceeds ${limits.maxObjects} objects or ${limits.maxDecodedBytes} decoded bytes`, + ); + } +} + async function hashBytes(bytes: Uint8Array): Promise { const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength)); copy.set(bytes); diff --git a/src/engines/immutable-snapshot.ts b/src/engines/immutable-snapshot.ts index 054692d..83de84b 100644 --- a/src/engines/immutable-snapshot.ts +++ b/src/engines/immutable-snapshot.ts @@ -28,11 +28,13 @@ import { isTrieTombstone, visibleTrieDocument, type TrieReadBundle, + type ReadBundleLimits, type TrieStoredDocument, type TrieTombstone, } from "../trie-protocol.js"; import { buildSecondaryIndexPage, + encodeSecondaryIndexPage, secondaryIndexDefinitionsEqual, secondaryIndexPageFromJson, type CollectionIndexConfiguration, @@ -45,6 +47,13 @@ type LoadedHead = { state: SnapshotHead; }; +type PreparedSecondaryIndex = { + key: string; + bytes: Uint8Array; + name: string; + reference: SecondaryIndexReference; +}; + export class ImmutableSnapshotEngine implements DatabaseEngine { readonly name = "immutable-snapshot"; private casRetries = 0; @@ -369,35 +378,82 @@ export class ImmutableSnapshotEngine implements DatabaseEngine { async readBundle( collection: string, id: string, + limits?: ReadBundleLimits, ): Promise { const normalized = validateName(collection, "Collection"); - const loaded = await this.loadCurrent(normalized); - const objects = []; - if (loaded.head.object) { - objects.push({ + const head = await this.loadHead(normalized); + const objects: TrieReadBundle["objects"] = []; + let decodedBytes = 0; + if (head.object) { + decodedBytes = addBundleObject( + objects, + decodedBytes, + { key: snapshotHeadKey(normalized), - etag: loaded.head.object.etag, - value: loaded.head.state as unknown as JsonValue, - }); + etag: head.object.etag, + value: head.state as unknown as JsonValue, + }, + head.object.bytes.byteLength, + limits, + ); + } + if (!head.state.snapshotHash) { + return { + collection: normalized, + id, + revision: head.state.revision, + document: null, + objects, + layout: "snapshot", + }; + } + if (limits) { + if (typeof head.state.decodedBytes !== "number") { + throw new BoundedReadError( + "Snapshot size metadata is unavailable for a bounded read bundle", + ); + } + assertBundleCapacity( + objects.length + 1, + decodedBytes + head.state.decodedBytes, + limits, + ); } - if (loaded.pageObject && loaded.head.state.snapshotHash) { - objects.push({ + const pageObject = await this.store.get( + snapshotPageKey( + normalized, + head.state.snapshotHash, + ), + ); + if (!pageObject) { + throw new Error( + `Snapshot ${head.state.snapshotHash} is missing`, + ); + } + const page = decodeJson(pageObject.bytes); + addBundleObject( + objects, + decodedBytes, + { key: snapshotPageKey( normalized, - loaded.head.state.snapshotHash, + head.state.snapshotHash, ), - etag: loaded.pageObject.etag, - value: loaded.page as unknown as JsonValue, - }); - } + etag: pageObject.etag, + value: page as unknown as JsonValue, + }, + pageObject.bytes.byteLength, + limits, + ); return { collection: normalized, id, - revision: loaded.head.state.revision, + revision: head.state.revision, document: visibleTrieDocument( - ownValue(loaded.page.documents, id), + ownValue(page.documents, id), ), objects, + layout: "snapshot", }; } @@ -421,8 +477,13 @@ export class ImmutableSnapshotEngine implements DatabaseEngine { if (!updateResult.changed) { return updateResult.result; } + const page: SnapshotPage = { documents }; const pageBytes = encodeJson(page as unknown as JsonValue); + const preparedIndexes = await this.prepareIndexes( + normalized, + Object.values(documents), + ); const snapshotHash = Object.keys(documents).length === 0 ? null @@ -430,9 +491,8 @@ export class ImmutableSnapshotEngine implements DatabaseEngine { normalized, pageBytes, ); - const indexes = await this.writeIndexes( - normalized, - Object.values(documents), + const indexes = await this.commitIndexes( + preparedIndexes, ); const nextHead: SnapshotHead = { revision: loaded.head.state.revision + 1, @@ -526,24 +586,47 @@ export class ImmutableSnapshotEngine implements DatabaseEngine { return hash; } - private async writeIndexes( + private async prepareIndexes( collection: string, documents: TrieStoredDocument[], - ): Promise { + ): Promise { const definitions = this.indexConfiguration[collection] ?? []; - const references = - createDictionary(); + const prepared: PreparedSecondaryIndex[] = []; for (const definition of definitions) { const page = buildSecondaryIndexPage( definition, documents, ); - const bytes = encodeJson(page as unknown as JsonValue); + const bytes = encodeSecondaryIndexPage(page); const hash = await this.addressSnapshot(bytes); + prepared.push({ + key: snapshotIndexKey( + collection, + definition.name, + hash, + ), + bytes, + name: definition.name, + reference: { + hash, + entries: page.entries.length, + decodedBytes: bytes.byteLength, + }, + }); + } + return prepared; + } + + private async commitIndexes( + prepared: PreparedSecondaryIndex[], + ): Promise { + const references = + createDictionary(); + for (const index of prepared) { try { await this.store.put( - snapshotIndexKey(collection, definition.name, hash), - bytes, + index.key, + index.bytes, { ifNoneMatch: true }, ); } catch (error) { @@ -551,11 +634,7 @@ export class ImmutableSnapshotEngine implements DatabaseEngine { throw error; } } - references[definition.name] = { - hash, - entries: page.entries.length, - decodedBytes: bytes.byteLength, - }; + references[index.name] = index.reference; } return references; } @@ -617,6 +696,40 @@ export class ImmutableSnapshotEngine implements DatabaseEngine { } } +function addBundleObject( + objects: TrieReadBundle["objects"], + decodedBytes: number, + object: TrieReadBundle["objects"][number], + objectBytes: number, + limits?: ReadBundleLimits, +): number { + const nextBytes = decodedBytes + objectBytes; + if (limits) { + assertBundleCapacity( + objects.length + 1, + nextBytes, + limits, + ); + } + objects.push(object); + return nextBytes; +} + +function assertBundleCapacity( + objects: number, + decodedBytes: number, + limits: ReadBundleLimits, +): void { + if ( + objects > limits.maxObjects || + decodedBytes > limits.maxDecodedBytes + ) { + throw new BoundedReadError( + `Read bundle exceeds ${limits.maxObjects} objects or ${limits.maxDecodedBytes} decoded bytes`, + ); + } +} + function assertUserDocument(document: JsonDocument): void { if ("__thimbleTombstone" in document) { throw new Error( diff --git a/src/query.ts b/src/query.ts index 91559e9..625f941 100644 --- a/src/query.ts +++ b/src/query.ts @@ -91,9 +91,21 @@ export function pointReadId( ) { return where.value; } + return null; } +export function queryFieldNames( + query: ThimbleQuery, +): string[] { + const fields = new Set(); + collectExpressionFields(query.where, fields); + for (const order of query.orderBy ?? []) { + fields.add(order.field); + } + return [...fields]; +} + export function validateThimbleQuery( query: ThimbleQuery, ): void { @@ -154,10 +166,12 @@ function validateExpression( if (depth > 12) { throw new Error("Query expression nesting exceeds 12 levels"); } + state.nodes += 1; if (state.nodes > 500) { throw new Error("Query expression exceeds 500 nodes"); } + if (!isRecord(expression)) { throw new Error("Query expression is malformed"); } @@ -227,6 +241,32 @@ function validateExpression( validateExpression(expression.not, depth + 1, state); } +function collectExpressionFields( + expression: QueryExpression | undefined, + fields: Set, +): void { + if (!expression) { + return; + } + if ("field" in expression) { + fields.add(expression.field); + return; + } + if ("and" in expression) { + expression.and.forEach((child) => + collectExpressionFields(child, fields), + ); + return; + } + if ("or" in expression) { + expression.or.forEach((child) => + collectExpressionFields(child, fields), + ); + return; + } + collectExpressionFields(expression.not, fields); +} + function evaluateExpression( document: T, expression: QueryExpression, diff --git a/src/read-bundle.ts b/src/read-bundle.ts new file mode 100644 index 0000000..5a4ac99 --- /dev/null +++ b/src/read-bundle.ts @@ -0,0 +1,39 @@ +import type { + ContentAddressedTrieEngine, +} from "./engines/content-trie.js"; +import type { + ImmutableSnapshotEngine, +} from "./engines/immutable-snapshot.js"; +import type { JsonValue } from "./core.js"; +import { encodeJson } from "./shared-utils.js"; +import { BoundedReadError } from "./core.js"; +import type { TrieReadBundle } from "./trie-protocol.js"; + +export const READ_BUNDLE_MAX_OBJECTS = 4; +export const READ_BUNDLE_MAX_DECODED_BYTES = + 4 * 1024 * 1024; + +type ReadBundleEngine = + | ContentAddressedTrieEngine + | ImmutableSnapshotEngine; + +export function readPointBundle( + engine: ReadBundleEngine, + collection: string, + id: string, +): Promise { + return engine.readBundle(collection, id, { + maxObjects: READ_BUNDLE_MAX_OBJECTS, + maxDecodedBytes: READ_BUNDLE_MAX_DECODED_BYTES, + }).then((bundle) => { + if ( + encodeJson(bundle as unknown as JsonValue).byteLength > + READ_BUNDLE_MAX_DECODED_BYTES + ) { + throw new BoundedReadError( + `Read bundle response exceeds ${READ_BUNDLE_MAX_DECODED_BYTES} decoded bytes`, + ); + } + return bundle; + }); +} diff --git a/src/secondary-index.ts b/src/secondary-index.ts index 48f6de5..0335e29 100644 --- a/src/secondary-index.ts +++ b/src/secondary-index.ts @@ -10,6 +10,7 @@ import type { } from "./query.js"; import { createDictionary, + encodeJson, validateName, } from "./shared-utils.js"; import { @@ -18,17 +19,50 @@ import { } from "./trie-protocol.js"; export type SecondaryIndexMode = "equality" | "range"; +export const MAX_COVERING_FIELDS = 8; +export const MAX_COVERING_DOCUMENT_BYTES = 64 * 1024; +export const MAX_SECONDARY_INDEX_PAGE_BYTES = + 4 * 1024 * 1024; + +export class SecondaryIndexLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "SecondaryIndexLimitError"; + } +} + +export function validateProjectionFields( + fields: string[], +): string[] { + if ( + fields.length < 1 || + fields.length > MAX_COVERING_FIELDS || + new Set(fields).size !== fields.length || + !fields.every( + (field) => isIndexFieldName(field) && field !== "id", + ) + ) { + throw new Error( + "Query projection requires 1-8 unique safe non-ID fields", + ); + } + return [...fields]; +} export type SecondaryIndexDefinition = { name: string; fields: string[]; mode: SecondaryIndexMode; + include?: string[]; }; export function defineIndex( name: string, fields: Array>, mode: SecondaryIndexMode = "equality", + options: { + include?: Array>; + } = {}, ): SecondaryIndexDefinition { const configuration = validateIndexConfiguration({ collection: [ @@ -36,6 +70,9 @@ export function defineIndex( name, fields, mode, + ...(options.include + ? { include: options.include } + : {}), }, ], }); @@ -67,6 +104,7 @@ export type SecondaryIndexPage = { version: 1; definition: SecondaryIndexDefinition; entries: SecondaryIndexEntry[]; + projections?: Record; }; export type SecondaryIndexChange = { @@ -104,15 +142,23 @@ export function validateIndexConfiguration( definition.fields.length > 4 || new Set(definition.fields).size !== definition.fields.length || - !definition.fields.every( - (field) => - typeof field === "string" && - /^[A-Za-z0-9_-]{1,64}$/.test(field), - ) || + !definition.fields.every(isIndexFieldName) || (definition.mode !== "equality" && definition.mode !== "range") || (definition.mode === "range" && - definition.fields.length !== 1) + definition.fields.length !== 1) || + (definition.include !== undefined && + (!Array.isArray(definition.include) || + definition.include.length < 1 || + definition.include.length > MAX_COVERING_FIELDS || + new Set(definition.include).size !== + definition.include.length || + !definition.include.every( + (field) => + isIndexFieldName(field) && + field !== "id" && + !definition.fields.includes(field), + ))) ) { throw new Error( `Invalid secondary index configuration for ${collection}`, @@ -123,6 +169,9 @@ export function validateIndexConfiguration( name: definition.name, fields: [...definition.fields], mode: definition.mode, + ...(definition.include + ? { include: [...definition.include] } + : {}), }; }); } @@ -163,16 +212,20 @@ export function buildSecondaryIndexPage( documents: Iterable, ): SecondaryIndexPage { const entries = new Map(); + const projections = definition.include + ? createDictionary() + : undefined; for (const stored of documents) { if (isTrieTombstone(stored)) { continue; } - addDocument(entries, definition, stored); + addDocument(entries, projections, definition, stored); } return { version: 1, definition, entries: sortEntries([...entries.values()]), + ...(projections ? { projections } : {}), }; } @@ -182,6 +235,11 @@ export function updateSecondaryIndexPage( changes: SecondaryIndexChange[], ): SecondaryIndexPage { const entries = new Map(); + const projections = definition.include + ? createDictionary( + current?.projections, + ) + : undefined; for (const entry of current?.entries ?? []) { entries.set(indexKey(entry.values), { values: [...entry.values], @@ -196,14 +254,23 @@ export function updateSecondaryIndexPage( } } for (const change of changes) { + if (projections) { + delete projections[change.id]; + } if (change.document && !isTrieTombstone(change.document)) { - addDocument(entries, definition, change.document); + addDocument( + entries, + projections, + definition, + change.document, + ); } } return { version: 1, definition, entries: sortEntries([...entries.values()]), + ...(projections ? { projections } : {}), }; } @@ -316,6 +383,7 @@ export function secondaryIndexPageFromJson( }).collection![0]!; const keys = new Set(); const documentIds = new Set(); + const indexedValues = new Map(); const entries = value.entries.map((entry) => { if ( typeof entry !== "object" || @@ -346,16 +414,24 @@ export function secondaryIndexPageFromJson( ); } documentIds.add(id); + indexedValues.set(id, values); } return { values, ids, }; }); + const projections = parseProjections( + value.projections, + definition, + documentIds, + indexedValues, + ); return { version: 1, definition, entries, + ...(projections ? { projections } : {}), }; } @@ -369,12 +445,18 @@ export function secondaryIndexDefinitionsEqual( left.fields.length === right.fields.length && left.fields.every( (field, index) => field === right.fields[index], + ) && + (left.include?.length ?? 0) === + (right.include?.length ?? 0) && + (left.include ?? []).every( + (field, index) => field === right.include?.[index], ) ); } function addDocument( entries: Map, + projections: Record | undefined, definition: SecondaryIndexDefinition, document: JsonDocument, ): void { @@ -393,6 +475,158 @@ function addDocument( entry.ids.sort(); } entries.set(key, entry); + if (projections) { + projections[document.id] = projectIndexDocument( + definition, + document, + ); + } +} + +export function documentsFromCoveringIndex< + T extends { id: string }, +>( + page: SecondaryIndexPage, + plan: SecondaryIndexPlan, + requiredFields: Iterable, +): JsonDocument[] | null { + if (!page.projections) { + return null; + } + const covered = new Set([ + "id", + ...page.definition.fields, + ...(page.definition.include ?? []), + ]); + if ( + [...requiredFields].some((field) => !covered.has(field)) + ) { + return null; + } + const ids = idsFromSecondaryIndex(page, plan); + const documents: JsonDocument[] = []; + for (const id of ids) { + const projection = page.projections[id]; + if (!projection) { + throw new Error( + `Secondary index projection is missing document ${id}`, + ); + } + documents.push(structuredClone(projection)); + } + return documents; +} + +function projectIndexDocument( + definition: SecondaryIndexDefinition, + document: JsonDocument, +): JsonDocument { + const projection: JsonDocument = { id: document.id }; + for (const field of [ + ...definition.fields, + ...(definition.include ?? []), + ]) { + if (document[field] !== undefined) { + projection[field] = structuredClone(document[field]); + } + } + if ( + encodeJson(projection as unknown as JsonValue).byteLength > + MAX_COVERING_DOCUMENT_BYTES + ) { + throw new SecondaryIndexLimitError( + `Secondary index covering projection exceeds ${MAX_COVERING_DOCUMENT_BYTES} decoded bytes for ${document.id}`, + ); + } + + return projection; +} + +export function encodeSecondaryIndexPage( + page: SecondaryIndexPage, +): Uint8Array { + const bytes = encodeJson(page as unknown as JsonValue); + if (bytes.byteLength > MAX_SECONDARY_INDEX_PAGE_BYTES) { + throw new SecondaryIndexLimitError( + `Secondary index ${page.definition.name} exceeds ${MAX_SECONDARY_INDEX_PAGE_BYTES} decoded bytes`, + ); + } + return bytes; +} + +function isIndexFieldName(value: unknown): value is string { + return ( + typeof value === "string" && + /^[A-Za-z0-9_-]{1,64}$/.test(value) && + value !== "__proto__" && + value !== "prototype" && + value !== "constructor" + ); +} + +function parseProjections( + value: unknown, + definition: SecondaryIndexDefinition, + documentIds: Set, + indexedValues: Map, +): Record | undefined { + if (!definition.include) { + if (value !== undefined) { + throw new Error( + "Secondary index page has undeclared projections", + ); + } + return undefined; + } + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) + ) { + throw new Error( + "Secondary index page is missing covering projections", + ); + } + const allowedFields = new Set([ + "id", + ...definition.fields, + ...definition.include, + ]); + const projections = createDictionary(); + for (const [id, candidate] of Object.entries(value)) { + if ( + !documentIds.has(id) || + typeof candidate !== "object" || + candidate === null || + Array.isArray(candidate) || + candidate.id !== id || + Object.keys(candidate).some( + (field) => !allowedFields.has(field), + ) + ) { + throw new Error( + "Secondary index covering projection is malformed", + ); + } + const projection = candidate as JsonDocument; + const values = indexedValues.get(id)!; + for (let index = 0; index < definition.fields.length; index += 1) { + if ( + projection[definition.fields[index]!] !== values[index] + ) { + throw new Error( + "Secondary index covering projection does not match its key", + ); + } + } + projections[id] = structuredClone(projection); + } + if (Object.keys(projections).length !== documentIds.size) { + throw new Error( + "Secondary index page is missing covering projections", + ); + } + return projections; } function flattenComparisons( diff --git a/src/server.ts b/src/server.ts index 46f4e6e..50eb0de 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,6 +40,7 @@ import type { JsonValue, ObjectStore, } from "./core.js"; +import { BoundedReadError } from "./core.js"; import { ContentAddressedTrieEngine } from "./engines/content-trie.js"; import { ImmutableSnapshotEngine } from "./engines/immutable-snapshot.js"; import { EnvelopeObjectStore } from "./envelope-store.js"; @@ -69,6 +70,7 @@ import { import type { CollectionLayout } from "./snapshot-protocol.js"; import { parseIndexConfiguration, + SecondaryIndexLimitError, validateIndexConfiguration, type CollectionIndexConfiguration, } from "./secondary-index.js"; @@ -83,6 +85,7 @@ import { studioDeletedDocuments, studioScopes, } from "./studio-api.js"; +import { readPointBundle } from "./read-bundle.js"; type ScopeRuntime = { material: ScopeMaterial; @@ -105,6 +108,7 @@ type ServerContext = { layoutGeneration: string; maintenanceMode: boolean; studioEnabled: boolean; + readBundlesEnabled: boolean; studioOrigin: string | null; developmentIdentity: boolean; oidcProviders: string[]; @@ -123,6 +127,7 @@ export type NodeAuthorityOptions = { collectionIndexes?: CollectionIndexConfiguration; collections?: string[]; studio?: boolean; + readBundles?: boolean; studioOrigin?: string; }; @@ -172,6 +177,9 @@ async function createContext( : requiredEnvironment("THIMBLE_ALLOWED_ORIGIN")); const studioEnabled = options.studio ?? process.env.THIMBLE_STUDIO === "true"; + const readBundlesEnabled = + options.readBundles ?? + process.env.THIMBLE_READ_BUNDLES === "true"; const studioOrigin = options.studioOrigin ?? process.env.THIMBLE_STUDIO_ORIGIN ?? @@ -302,6 +310,7 @@ async function createContext( maintenanceMode: process.env.THIMBLE_MAINTENANCE_MODE === "true", studioEnabled, + readBundlesEnabled, studioOrigin, developmentIdentity, oidcProviders: [...identityAdapters.keys()].filter( @@ -837,6 +846,9 @@ async function handleRequest( name: "ThimbleDB", provider: context.provider, readBaseUrl: "/api/objects", + ...(context.readBundlesEnabled + ? { readBundleBaseUrl: "/api/read-bundles" } + : {}), headTtlMs: context.headTtlMs, cachePolicy: "content", collectionLayouts: context.collectionLayouts, @@ -891,6 +903,47 @@ async function handleRequest( return; } + const readBundleRoute = + /^\/api\/read-bundles\/([^/]+)\/([^/]+)\/([^/]+)$/.exec( + url.pathname, + ); + if ( + request.method === "GET" && + context.readBundlesEnabled && + readBundleRoute?.[1] && + readBundleRoute[2] && + readBundleRoute[3] + ) { + requireAuthenticated(authenticated); + const scopeId = decodePathSegment(readBundleRoute[1]); + requireGrant(authenticated.session.grants, scopeId, "read"); + const collection = decodePathSegment(readBundleRoute[2]); + const id = decodePathSegment(readBundleRoute[3]); + const runtime = await context.scope(scopeId); + try { + const bundle = await readPointBundle( + engineFor(context, runtime, collection), + collection, + id, + ); + sendJson(response, 200, bundle, { + "x-thimble-bundle-objects": String( + bundle.objects.length, + ), + }); + } catch (error) { + if (error instanceof BoundedReadError) { + throw new AuthError( + 413, + "read_bundle_unavailable", + error.message, + ); + } + throw error; + } + return; + } + if ( request.method === "GET" && url.pathname.startsWith("/api/objects/") @@ -2107,26 +2160,51 @@ function handleServerError( error: unknown, response: ServerResponse, ): void { - if (!(error instanceof AuthError && error.status < 500)) { - console.error(error); + const handledError = + error instanceof SecondaryIndexLimitError + ? new AuthError( + 413, + "secondary_index_too_large", + error.message, + ) + : error; + if ( + !( + handledError instanceof AuthError && + handledError.status < 500 + ) + ) { + console.error(handledError); } if (response.headersSent) { response.end(); return; } - const status = error instanceof AuthError ? error.status : 500; + const status = + handledError instanceof AuthError + ? handledError.status + : 500; const headers: Record = {}; - if (error instanceof AuthError && error.retryAfterSeconds) { - headers["retry-after"] = String(error.retryAfterSeconds); + if ( + handledError instanceof AuthError && + handledError.retryAfterSeconds + ) { + headers["retry-after"] = String( + handledError.retryAfterSeconds, + ); } sendJson( response, status, { error: - error instanceof AuthError ? error.code : "internal_error", + handledError instanceof AuthError + ? handledError.code + : "internal_error", message: - error instanceof AuthError ? error.message : "Request failed", + handledError instanceof AuthError + ? handledError.message + : "Request failed", }, headers, ); diff --git a/src/trie-protocol.ts b/src/trie-protocol.ts index 5ef215a..875b6f6 100644 --- a/src/trie-protocol.ts +++ b/src/trie-protocol.ts @@ -60,6 +60,12 @@ export type TrieReadBundle = { revision: number; document: JsonDocument | null; objects: TrieBundleObject[]; + layout?: "trie" | "snapshot"; +}; + +export type ReadBundleLimits = { + maxObjects: number; + maxDecodedBytes: number; }; export function trieCollectionPrefix(collection: string): string { diff --git a/studio/src/main.ts b/studio/src/main.ts index 2b0b7b1..9b1a80f 100644 --- a/studio/src/main.ts +++ b/studio/src/main.ts @@ -40,6 +40,7 @@ type StudioIndex = { name: string; fields: string[]; mode: "equality" | "range"; + include?: string[]; }; active: boolean; entries: number | null; @@ -1073,7 +1074,7 @@ function renderIndexes(): void { } const table = document.createElement("table"); table.innerHTML = - "IndexModeFieldsStatusEntries"; + "IndexModeFieldsCoversStatusEntries"; const body = document.createElement("tbody"); for (const index of collection.indexes) { const row = document.createElement("tr"); @@ -1081,6 +1082,7 @@ function renderIndexes(): void { index.definition.name, index.definition.mode, index.definition.fields.join(", "), + index.definition.include?.join(", ") ?? "None", index.status, index.entries === null ? "—" : String(index.entries), ]) { diff --git a/templates/local-web/server.mjs b/templates/local-web/server.mjs index c799c05..ee533b9 100644 --- a/templates/local-web/server.mjs +++ b/templates/local-web/server.mjs @@ -10,6 +10,7 @@ const { startNodeAuthority } = await import( await startNodeAuthority({ studio: true, + readBundles: true, collections: ["notes"], collectionLayouts: { notes: "snapshot", @@ -20,6 +21,7 @@ await startNodeAuthority({ name: "by-title", fields: ["title"], mode: "equality", + include: ["lastModified"], }, { name: "by-last-modified", diff --git a/templates/local-web/src/main.ts b/templates/local-web/src/main.ts index 35078e2..ee95808 100644 --- a/templates/local-web/src/main.ts +++ b/templates/local-web/src/main.ts @@ -32,9 +32,37 @@ const noteSchema = { }, }; +type NoteSummary = Pick< + Note, + "id" | "title" | "lastModified" +>; + +const noteSummarySchema = { + parse(value: unknown): NoteSummary { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" || + !("title" in value) || + typeof value.title !== "string" || + !("lastModified" in value) || + typeof value.lastModified !== "number" + ) { + throw new Error("Invalid note summary"); + } + return value as NoteSummary; + }, +}; + const noteDefinition = defineCollection("notes", noteSchema, { indexes: [ - defineIndex("by-title", ["title"]), + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["lastModified"] }, + ), defineIndex( "by-last-modified", ["lastModified"], @@ -90,6 +118,10 @@ element("filter").addEventListener( .where((note) => note.title.eq(title)) .orderBy((note) => note.lastModified.desc()) .take(50) + .select( + ["title", "lastModified"], + noteSummarySchema, + ) .get(); status.textContent = `${result.documents.length} notes via ${result.plan}` + @@ -130,14 +162,21 @@ async function renderAll() { renderNotes(result.documents); } -function renderNotes(items: Note[]) { +function renderNotes( + items: Array< + Note | NoteSummary + >, +) { notesOutput.replaceChildren( ...items.map((note) => { const article = document.createElement("article"); const title = document.createElement("h2"); title.textContent = note.title; const body = document.createElement("p"); - body.textContent = note.body || "No body"; + body.textContent = + "body" in note + ? note.body || "No body" + : "Use Show all to load the note body."; const metadata = document.createElement("small"); metadata.textContent = new Date( note.lastModified, diff --git a/tests/browser-client.test.ts b/tests/browser-client.test.ts index 8561a72..ca92502 100644 --- a/tests/browser-client.test.ts +++ b/tests/browser-client.test.ts @@ -11,6 +11,7 @@ import { import { ThimbleClient } from "../src/browser/client.js"; import type { JsonObjectReader, + PointReadBundleReader, RemoteJsonObject, } from "../src/browser/remote-reader.js"; import { HttpObjectReadError } from "../src/browser/remote-reader.js"; @@ -25,6 +26,80 @@ import { } from "../src/trie-protocol.js"; describe("ThimbleDB browser client", () => { + it("uses one cold read bundle and reuses its cached objects", async () => { + const fixture = trieFixture("products", "product-bundle"); + const bundleReader = new FakeBundleReader({ + status: "found", + bytes: 512, + bundle: { + collection: "products", + id: fixture.id, + revision: 1, + document: fixture.document, + objects: [...fixture.objects].map(([key, object]) => ({ + key, + etag: object.etag, + value: structuredClone(object.value), + })), + layout: "trie", + }, + }); + const objectReader = new FakeReader(fixture.objects); + const cache = cacheFor("content", uniqueName()); + const client = new ThimbleClient({ + reader: objectReader, + bundleReader, + cache, + headTtlMs: 10_000, + channelName: uniqueName(), + }); + + await expect( + client.get("products", fixture.id), + ).resolves.toEqual(fixture.document); + await expect( + client.get("products", fixture.id), + ).resolves.toEqual(fixture.document); + + expect(bundleReader.calls).toBe(1); + expect(objectReader.calls).toBe(0); + expect(client.metrics()).toMatchObject({ + remoteReads: 1, + remoteBytes: 512, + bundleReads: 1, + bundleBytes: 512, + bundleFallbacks: 0, + }); + }); + + it("falls back to individual objects when a bundle is unavailable", async () => { + const fixture = trieFixture("products", "product-fallback"); + const bundleReader = new FakeBundleReader({ + status: "fallback", + }); + const objectReader = new FakeReader(fixture.objects); + const cache = cacheFor("content", uniqueName()); + const client = new ThimbleClient({ + reader: objectReader, + bundleReader, + cache, + headTtlMs: 10_000, + channelName: uniqueName(), + }); + + await expect( + client.get("products", fixture.id), + ).resolves.toEqual(fixture.document); + + expect(bundleReader.calls).toBe(1); + expect(objectReader.calls).toBe(4); + expect(client.metrics()).toMatchObject({ + remoteReads: 5, + bundleReads: 1, + bundleFallbacks: 1, + }); + }); + it("serves warm content reads from memory and revalidates HEAD", async () => { const fixture = trieFixture("products", "product-00001"); const reader = new FakeReader(fixture.objects); @@ -956,6 +1031,7 @@ class FakeReader implements JsonObjectReader { if (this.offline) { throw new Error("offline"); } + if (this.error) { throw this.error; } @@ -980,6 +1056,25 @@ class FakeReader implements JsonObjectReader { } } +class FakeBundleReader implements PointReadBundleReader { + calls = 0; + + constructor( + private readonly result: + | { + status: "found"; + bundle: import("../src/trie-protocol.js").TrieReadBundle; + bytes: number; + } + | { status: "fallback" }, + ) {} + + get() { + this.calls += 1; + return Promise.resolve(structuredClone(this.result)); + } +} + function cacheFor( policy: "content" | "locations", databaseName: string, diff --git a/tests/browser-connect.test.ts b/tests/browser-connect.test.ts index 1207eb1..deb7704 100644 --- a/tests/browser-connect.test.ts +++ b/tests/browser-connect.test.ts @@ -110,6 +110,77 @@ describe("browser connection factory", () => { client.close(); }); + it("uses an advertised point-read bundle endpoint", async () => { + const requests: string[] = []; + const client = await createThimbleClient({ + configurationUrl: "https://app.example.test/api/config", + persistentCache: false, + fetchImplementation: async (input) => { + const url = String(input); + requests.push(url); + if (url.endsWith("/api/config")) { + return Response.json({ + ...browserConfig(false), + readBundleBaseUrl: "/api/read-bundles", + }); + } + if ( + url.endsWith( + "/api/read-bundles/user%3Auser-1/notes/note-1", + ) + ) { + return Response.json({ + collection: "notes", + id: "note-1", + revision: 1, + document: { + id: "note-1", + title: "Bundled", + }, + objects: [ + { + key: "content-snapshot/notes/HEAD.json", + etag: "head", + value: { + revision: 1, + snapshotHash: "snapshot-one", + }, + }, + { + key: + "content-snapshot/notes/snapshots/" + + "snapshot-one.json", + etag: "snapshot", + value: { + documents: { + "note-1": { + id: "note-1", + title: "Bundled", + }, + }, + }, + }, + ], + layout: "snapshot", + }); + } + return new Response(null, { status: 404 }); + }, + }); + + await expect( + client.get("notes", "note-1"), + ).resolves.toEqual({ + id: "note-1", + title: "Bundled", + }); + expect(requests).toEqual([ + "https://app.example.test/api/config", + "https://app.example.test/api/read-bundles/" + + "user%3Auser-1/notes/note-1", + ]); + }); + it("rejects malformed authority configuration", async () => { await expect( createThimbleClient({ diff --git a/tests/cloudflare-worker.test.ts b/tests/cloudflare-worker.test.ts index 038fcde..8d8111e 100644 --- a/tests/cloudflare-worker.test.ts +++ b/tests/cloudflare-worker.test.ts @@ -170,6 +170,24 @@ describe("Cloudflare Worker request parsing", () => { await expect(missingOrigin.json()).resolves.toMatchObject({ error: "origin_rejected", }); + + const disabledBundle = await createCloudflareAuthority().fetch( + new Request( + "https://db.example.test/api/read-bundles/public/notes/note-1", + ), + environment as never, + ); + expect(disabledBundle.status).toBe(404); + + const bundle = await createCloudflareAuthority({ + readBundles: true, + }).fetch( + new Request( + "https://db.example.test/api/read-bundles/public/notes/note-1", + ), + environment as never, + ); + expect(bundle.status).toBe(401); }); it("does not apply Studio catalog limits when Studio is disabled", async () => { diff --git a/tests/collection.test.ts b/tests/collection.test.ts index ed95054..9f1af14 100644 --- a/tests/collection.test.ts +++ b/tests/collection.test.ts @@ -121,6 +121,150 @@ describe("typed collections", () => { ], limit: 10, }); + + }); + + it("requests explicit typed projections without full schema parsing", async () => { + type DetailedNote = Note & { + body: string; + lastModified: number; + }; + const queryDocuments = vi.fn().mockResolvedValue({ + documents: [ + { + id: "note-1", + title: "Projected", + }, + ], + plan: "index", + indexName: "by-title", + scannedDocuments: 1, + }); + const collection = new ThimbleCollection( + client({ queryDocuments }), + defineCollection("notes"), + ); + const projectedSchema = { + parse(value: unknown) { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" || + !("title" in value) || + typeof value.title !== "string" + ) { + throw new Error("Invalid projected note"); + } + return value as Pick; + }, + }; + const builder = collection + .where((note) => note.title.eq("Projected")) + .select(["title"], projectedSchema); + + await expect(builder.get()).resolves.toMatchObject({ + documents: [ + { + id: "note-1", + title: "Projected", + }, + ], + }); + expect(queryDocuments).toHaveBeenCalledWith( + "notes", + { + version: 1, + where: { + field: "title", + operator: "eq", + value: "Projected", + }, + }, + ["title"], + ); + expect(builder.toJSON()).toEqual({ + query: { + version: 1, + where: { + field: "title", + operator: "eq", + value: "Projected", + }, + }, + select: ["title"], + }); + }); + + it("rejects projected values that fail their projection schema", async () => { + type DetailedNote = Note & { + body: string; + }; + const collection = new ThimbleCollection( + client({ + queryDocuments: async () => ({ + documents: [ + { + id: "note-1", + title: 123, + } as never, + ], + plan: "index", + indexName: "by-title", + scannedDocuments: 1, + }), + }), + defineCollection("notes"), + ); + + await expect( + collection + .where((note) => note.title.eq("Projected")) + .select(["title"], { + parse(value: unknown) { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" || + !("title" in value) || + typeof value.title !== "string" + ) { + throw new Error("Invalid projected note"); + } + return value as Pick< + DetailedNote, + "id" | "title" + >; + }, + }) + .get(), + ).rejects.toThrow( + "Projection validation failed in collection notes", + ); + }); + + it("rejects prototype-sensitive projection fields", () => { + type FlexibleNote = Note & { + __proto__?: string; + }; + const collection = new ThimbleCollection( + client({}), + defineCollection("notes"), + ); + + expect(() => + collection + .where((note) => note.title.eq("example")) + .select(["__proto__"], { + parse(value: unknown) { + return value as Pick< + FlexibleNote, + "id" | "__proto__" + >; + }, + }), + ).toThrow("unique safe non-ID fields"); }); it("surfaces collection and document context on validation failure", async () => { diff --git a/tests/e2e/authenticated-store.spec.ts b/tests/e2e/authenticated-store.spec.ts index f604601..b4815c6 100644 --- a/tests/e2e/authenticated-store.spec.ts +++ b/tests/e2e/authenticated-store.spec.ts @@ -5,6 +5,12 @@ test("authenticates externally, reads, writes, persists cache, and logs out", as browserName, request, }) => { + const bundleRequests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/api/read-bundles/")) { + bundleRequests.push(request.url()); + } + }); const subject = `${browserName}-${crypto.randomUUID()}`; const token = await request .get( @@ -50,10 +56,52 @@ test("authenticates externally, reads, writes, persists cache, and logs out", as '"products": 128', ); + const oversizedProjection = await page.evaluate(async () => { + const config = await fetch("/api/config", { + credentials: "same-origin", + cache: "no-store", + }).then((response) => response.json()) as { + csrfToken: string; + layoutGeneration: string; + scope: { id: string }; + }; + const response = await fetch( + "/api/collections/products/documents/oversized-cover", + { + method: "POST", + credentials: "same-origin", + headers: { + "content-type": "application/json", + "x-thimble-csrf": config.csrfToken, + "x-thimble-scope": config.scope.id, + "x-thimble-layout-generation": + config.layoutGeneration, + }, + body: JSON.stringify({ + id: "oversized-cover", + sku: "OVERSIZED", + name: "x".repeat(70 * 1024), + priceCents: 1, + }), + }, + ); + return { + status: response.status, + body: await response.json(), + }; + }); + expect(oversizedProjection).toMatchObject({ + status: 413, + body: { + error: "secondary_index_too_large", + }, + }); + await page.locator("#read-product").click(); await expect(page.locator("#product-output")).toContainText( "product-00000", ); + expect(bundleRequests).toHaveLength(1); await page.locator("#delete-product").click(); await expect(page.locator("#status")).toContainText( diff --git a/tests/read-bundle.test.ts b/tests/read-bundle.test.ts new file mode 100644 index 0000000..097dd0b --- /dev/null +++ b/tests/read-bundle.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { BoundedReadError } from "../src/core.js"; +import { ContentAddressedTrieEngine } from "../src/engines/content-trie.js"; +import { ImmutableSnapshotEngine } from "../src/engines/immutable-snapshot.js"; +import { LocalObjectStore } from "../src/providers/local.js"; +import { readPointBundle } from "../src/read-bundle.js"; + +describe("bounded point-read bundles", () => { + it.each(["trie", "snapshot"] as const)( + "returns the current %s document and cache objects", + async (layout) => { + const directory = await mkdtemp( + path.join(os.tmpdir(), `thimble-bundle-${layout}-`), + ); + try { + const store = new LocalObjectStore(directory); + const engine = + layout === "trie" + ? new ContentAddressedTrieEngine(store) + : new ImmutableSnapshotEngine(store); + await engine.put("notes", "note-1", { + id: "note-1", + title: "Bundled", + }); + + const bundle = await engine.readBundle( + "notes", + "note-1", + { + maxObjects: 4, + maxDecodedBytes: 1024 * 1024, + }, + ); + + expect(bundle.layout).toBe(layout); + expect(bundle.document).toEqual({ + id: "note-1", + title: "Bundled", + }); + expect(bundle.objects).toHaveLength( + layout === "trie" ? 4 : 2, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + it("rejects an oversized snapshot before loading its page", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-bundle-snapshot-limit-"), + ); + try { + const store = new LocalObjectStore(directory); + const engine = new ImmutableSnapshotEngine(store); + await engine.put("notes", "note-1", { + id: "note-1", + body: "x".repeat(4_096), + }); + let pageReads = 0; + const originalGet = store.get.bind(store); + store.get = async (key) => { + if (key.includes("/snapshots/")) { + pageReads += 1; + } + return originalGet(key); + }; + + await expect( + engine.readBundle("notes", "note-1", { + maxObjects: 4, + maxDecodedBytes: 100, + }), + ).rejects.toBeInstanceOf(BoundedReadError); + expect(pageReads).toBe(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("rejects a trie bundle before loading a disallowed leaf", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-bundle-trie-limit-"), + ); + try { + const store = new LocalObjectStore(directory); + const engine = new ContentAddressedTrieEngine(store); + await engine.put("notes", "note-1", { + id: "note-1", + title: "Bounded", + }); + let nodeReads = 0; + const originalGet = store.get.bind(store); + store.get = async (key) => { + if (key.includes("/nodes/")) { + nodeReads += 1; + } + return originalGet(key); + }; + + await expect( + engine.readBundle("notes", "note-1", { + maxObjects: 3, + maxDecodedBytes: 1024 * 1024, + }), + ).rejects.toBeInstanceOf(BoundedReadError); + expect(nodeReads).toBe(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("bounds the complete serialized response including the document copy", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-bundle-response-limit-"), + ); + try { + const store = new LocalObjectStore(directory); + const engine = new ImmutableSnapshotEngine(store); + await engine.put("notes", "note-1", { + id: "note-1", + body: "x".repeat(2_200_000), + }); + + await expect( + readPointBundle(engine, "notes", "note-1"), + ).rejects.toBeInstanceOf(BoundedReadError); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/secondary-index.test.ts b/tests/secondary-index.test.ts index a545f88..16bf71f 100644 --- a/tests/secondary-index.test.ts +++ b/tests/secondary-index.test.ts @@ -23,6 +23,7 @@ import { type RemoteJsonObject, type SnapshotHead, type TrieHead, + MAX_SECONDARY_INDEX_PAGE_BYTES, } from "../src/index.js"; import { LocalObjectStore } from "../src/providers/local.js"; @@ -33,6 +34,49 @@ type Note = { tags?: string[]; }; +const noteSummarySchema = { + parse(value: unknown): Pick< + Note, + "id" | "title" | "lastModified" + > { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" || + !("title" in value) || + typeof value.title !== "string" || + !("lastModified" in value) || + typeof value.lastModified !== "number" + ) { + throw new Error("Invalid note summary"); + } + return value as Pick< + Note, + "id" | "title" | "lastModified" + >; + }, +}; + +const noteTagsSchema = { + parse(value: unknown): Pick { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" || + !("title" in value) || + typeof value.title !== "string" || + !("tags" in value) || + !Array.isArray(value.tags) || + !value.tags.every((tag) => typeof tag === "string") + ) { + throw new Error("Invalid note tags"); + } + return value as Pick; + }, +}; + const indexes: CollectionIndexConfiguration = { notes: [ { @@ -209,6 +253,7 @@ describe("secondary indexes", () => { }, collectionIndexes: indexes, }); + const result = await client .collection("notes") .where((note) => note.title.eq("same")) @@ -225,6 +270,475 @@ describe("secondary indexes", () => { } }); + it.each(["snapshot", "trie"] as const)( + "maintains explicit covering projections for %s indexes", + async (layout) => { + const directory = await mkdtemp( + path.join( + os.tmpdir(), + `thimble-covering-${layout}-`, + ), + ); + try { + const store = new LocalObjectStore(directory); + const covering: CollectionIndexConfiguration = { + notes: [ + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["lastModified"] }, + ), + ], + }; + const engine = + layout === "snapshot" + ? new ImmutableSnapshotEngine( + store, + 40, + address, + false, + covering, + ) + : new ContentAddressedTrieEngine( + store, + 40, + address, + false, + covering, + ); + await engine.putMany("notes", [ + { + id: "note-1", + title: "same", + lastModified: 1, + tags: ["first"], + }, + { + id: "note-2", + title: "same", + lastModified: 2, + tags: ["second"], + }, + ]); + + let page = await coveringPage(store, layout); + expect(page.projections).toEqual({ + "note-1": { + id: "note-1", + title: "same", + lastModified: 1, + }, + "note-2": { + id: "note-2", + title: "same", + lastModified: 2, + }, + }); + + await engine.put("notes", "note-1", { + id: "note-1", + title: "same", + lastModified: 3, + tags: ["updated"], + }); + page = await coveringPage(store, layout); + expect(page.projections?.["note-1"]).toEqual({ + id: "note-1", + title: "same", + lastModified: 3, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + it("serves an explicit projection without loading full documents", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-covering-query-"), + ); + try { + const store = new LocalObjectStore(directory); + const covering: CollectionIndexConfiguration = { + notes: [ + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["lastModified"] }, + ), + ], + }; + const engine = new ImmutableSnapshotEngine( + store, + 40, + address, + false, + covering, + ); + await engine.putMany("notes", [ + { + id: "note-1", + title: "same", + lastModified: 2, + tags: ["private"], + }, + { + id: "note-2", + title: "same", + lastModified: 1, + tags: ["private"], + }, + ]); + const calls = new Map(); + const client = new ThimbleClient({ + reader: objectReader(store, calls), + cache: new TieredObjectCache( + new MemoryObjectCache(), + new NullPersistentCache(), + ), + headTtlMs: 10_000, + collectionLayouts: { notes: "snapshot" }, + collectionIndexes: covering, + }); + + const result = await client + .collection("notes") + .where((note) => note.title.eq("same")) + .orderBy((note) => note.lastModified.asc()) + .select( + ["title", "lastModified"], + noteSummarySchema, + ) + .get(); + const head = (await readHeadForIndex( + store, + "snapshot", + "by-title", + )) as SnapshotHead; + + expect(result.plan).toBe("index"); + expect(result.documents).toEqual([ + { + id: "note-2", + title: "same", + lastModified: 1, + }, + { + id: "note-1", + title: "same", + lastModified: 2, + }, + ]); + expect( + calls.get(snapshotPageKey("notes", head.snapshotHash!)), + ).toBeUndefined(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("loads full documents when a selected field is not covered", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-covering-fallback-"), + ); + try { + const store = new LocalObjectStore(directory); + const covering: CollectionIndexConfiguration = { + notes: [ + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["lastModified"] }, + ), + ], + }; + const engine = new ImmutableSnapshotEngine( + store, + 40, + address, + false, + covering, + ); + await engine.put("notes", "note-1", { + id: "note-1", + title: "same", + lastModified: 1, + tags: ["required"], + }); + + const calls = new Map(); + const client = new ThimbleClient({ + reader: objectReader(store, calls), + cache: new TieredObjectCache( + new MemoryObjectCache(), + new NullPersistentCache(), + ), + headTtlMs: 10_000, + collectionLayouts: { notes: "snapshot" }, + collectionIndexes: covering, + }); + + const result = await client + .collection("notes") + .where((note) => note.title.eq("same")) + .select( + ["title", "tags"], + noteTagsSchema, + ) + .get(); + const head = (await readHeadForIndex( + store, + "snapshot", + "by-title", + )) as SnapshotHead; + + expect(result.documents).toEqual([ + { + id: "note-1", + title: "same", + tags: ["required"], + }, + ]); + expect( + calls.get(snapshotPageKey("notes", head.snapshotHash!)), + ).toBe(1); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it.each(["snapshot", "trie"] as const)( + "rejects oversized %s projections before writing content objects", + async (layout) => { + const directory = await mkdtemp( + path.join( + os.tmpdir(), + `thimble-covering-size-${layout}-`, + ), + ); + try { + const store = new LocalObjectStore(directory); + const covering: CollectionIndexConfiguration = { + notes: [ + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["tags"] }, + ), + ], + }; + const engine = + layout === "snapshot" + ? new ImmutableSnapshotEngine( + store, + 40, + address, + false, + covering, + ) + : new ContentAddressedTrieEngine( + store, + 40, + address, + false, + covering, + ); + + await expect( + engine.put("notes", "note-1", { + id: "note-1", + title: "large", + lastModified: 1, + tags: ["x".repeat(70 * 1024)], + }), + ).rejects.toThrow( + "covering projection exceeds 65536 decoded bytes", + ); + expect( + await store.list( + layout === "snapshot" + ? "content-snapshot/notes/" + : "content-trie/notes/", + ), + ).toEqual([]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + it("rejects aggregate covering index pages above four MiB", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-covering-page-size-"), + ); + try { + const store = new LocalObjectStore(directory); + const covering: CollectionIndexConfiguration = { + notes: [ + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["tags"] }, + ), + ], + }; + const engine = new ImmutableSnapshotEngine( + store, + 40, + address, + false, + covering, + ); + + await expect( + engine.putMany( + "notes", + Array.from({ length: 70 }, (_, index) => ({ + id: `note-${index}`, + title: `title-${index}`, + lastModified: index, + tags: ["x".repeat(60_000)], + })), + ), + ).rejects.toThrow( + `exceeds ${MAX_SECONDARY_INDEX_PAGE_BYTES} decoded bytes`, + ); + expect( + await store.list("content-snapshot/notes/"), + ).toEqual([]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("projects stale covering definitions after bounded scan fallback", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-covering-stale-"), + ); + try { + const store = new LocalObjectStore(directory); + const oldIndexes: CollectionIndexConfiguration = { + notes: [ + defineIndex("by-title", ["title"]), + ], + }; + await new ImmutableSnapshotEngine( + store, + 40, + address, + false, + oldIndexes, + ).put("notes", "note-1", { + id: "note-1", + title: "same", + lastModified: 1, + secret: "must-not-return", + }); + const covering: CollectionIndexConfiguration = { + notes: [ + defineIndex( + "by-title", + ["title"], + "equality", + { include: ["lastModified"] }, + ), + ], + }; + const result = await browserClient( + store, + "snapshot", + covering, + ) + .collection("notes") + .where((note) => note.title.eq("same")) + .select(["title"], { + parse(value: unknown) { + return value as Pick; + }, + }) + .get(); + + expect(result.plan).toBe("scan"); + expect(result.documents).toEqual([ + { + id: "note-1", + title: "same", + }, + ]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("bypasses oversized index pages from authenticated HEAD metadata", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "thimble-index-client-size-"), + ); + try { + const store = new LocalObjectStore(directory); + const engine = new ImmutableSnapshotEngine( + store, + 40, + address, + false, + indexes, + ); + await engine.put("notes", "note-1", { + id: "note-1", + title: "same", + lastModified: 1, + }); + const headObject = await store.get( + snapshotHeadKey("notes"), + ); + const head = JSON.parse( + Buffer.from(headObject!.bytes).toString("utf8"), + ) as SnapshotHead; + head.indexes!["by-title"]!.decodedBytes = + MAX_SECONDARY_INDEX_PAGE_BYTES + 1; + await store.put( + snapshotHeadKey("notes"), + Buffer.from(JSON.stringify(head)), + { ifMatch: headObject!.etag }, + ); + const calls = new Map(); + const client = new ThimbleClient({ + reader: objectReader(store, calls), + cache: new TieredObjectCache( + new MemoryObjectCache(), + new NullPersistentCache(), + ), + headTtlMs: 10_000, + collectionLayouts: { notes: "snapshot" }, + collectionIndexes: indexes, + }); + + const result = await client + .collection("notes") + .where((note) => note.title.eq("same")) + .get(); + const reference = head.indexes!["by-title"]!; + + expect(result.plan).toBe("scan"); + expect( + calls.get( + snapshotIndexKey( + "notes", + "by-title", + reference.hash, + ), + ), + ).toBeUndefined(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it("falls back for a stale definition and rebuilds it on the next trie write", async () => { const directory = await mkdtemp( path.join(os.tmpdir(), "thimble-index-definition-"), @@ -327,6 +841,20 @@ describe("secondary indexes", () => { expect(() => defineIndex("duplicate", ["title", "title"]), ).toThrow("Invalid secondary index configuration"); + expect(() => + defineIndex( + "invalid-cover", + ["title"], + "equality", + { include: ["title"] }, + ), + ).toThrow("Invalid secondary index configuration"); + expect(() => + defineIndex( + "prototype-field", + ["__proto__" as keyof Note], + ), + ).toThrow("Invalid secondary index configuration"); expect(() => secondaryIndexPageFromJson({ @@ -348,6 +876,24 @@ describe("secondary indexes", () => { ], }), ).toThrow("duplicate document"); + + expect(() => + secondaryIndexPageFromJson({ + version: 1, + definition: { + name: "by-title", + fields: ["title"], + mode: "equality", + include: ["lastModified"], + }, + entries: [ + { + values: ["First"], + ids: ["note-1"], + }, + ], + }), + ).toThrow("missing covering projections"); }); it("does not use a sparse range index for ordering alone", () => { @@ -450,6 +996,7 @@ describe("secondary indexes", () => { } finally { await rm(directory, { recursive: true, force: true }); } + }); it.each(["snapshot", "trie"] as const)( @@ -633,6 +1180,53 @@ describe("secondary indexes", () => { ); }); +async function readHeadForIndex( + store: LocalObjectStore, + layout: "snapshot" | "trie", + indexName: string, +): Promise { + const key = + layout === "snapshot" + ? snapshotHeadKey("notes") + : trieHeadKey("notes"); + const object = await store.get(key); + if (!object) { + throw new Error("Collection head is missing"); + } + const head = JSON.parse( + Buffer.from(object.bytes).toString("utf8"), + ) as SnapshotHead | TrieHead; + if (!head.indexes?.[indexName]) { + throw new Error(`Index ${indexName} is missing`); + } + return head; +} + +async function coveringPage( + store: LocalObjectStore, + layout: "snapshot" | "trie", +) { + const head = await readHeadForIndex( + store, + layout, + "by-title", + ); + const reference = head.indexes!["by-title"]!; + const key = + layout === "snapshot" + ? snapshotIndexKey("notes", "by-title", reference.hash) + : trieIndexKey("notes", "by-title", reference.hash); + const object = await store.get(key); + if (!object) { + throw new Error("Covering index page is missing"); + } + return secondaryIndexPageFromJson( + JSON.parse( + Buffer.from(object.bytes).toString("utf8"), + ) as JsonValue, + ); +} + async function readHead( store: LocalObjectStore, layout: "snapshot" | "trie",