feat(plugins): extend the output allowlist to child rows and whole-row read models

The output contract cut only the top level of an entity result. A derived
key's value went out untouched, so the children that are whole rows still
carried every column: a day's notes_items are full day_notes entities and
a reservation's endpoints are SELECT e.* on reservation_endpoints. About
fifty methods were also left as readModel and passed through whole, many
of them backed by SELECT * reads (day_accommodations, day_notes, todos,
tags, packing bags, journal entries, collab, collections, vacay plans).
A new column on any of those tables reached every plugin by default,
which is what the contract was meant to stop and what the SDK README and
the wiki already claimed it did.

PLUGIN_ENTITY_NESTED now names, per entity, the derived keys that hold
rows of another entity, and the shaper cuts those rows down to their own
field list, recursively. dayNote and reservationEndpoint are the child
tables that were whole rows; day.assignments and place.tags are held to
the assignment and tag lists too, although their reads already name
their columns. The remaining derived children (travelers, participants,
ratings, budget members, payers and receipts, packing recipients,
attachments, voters, bag members) are built field by field, which the
contract comment now states as the rule for a derived key without a
nested entity.

The whole-row read models get entity contracts: accommodation, dayNote,
todo, tag, category, packingBag, fileLink, collabNote, collabPoll,
collabMessage, journey, journalEntry, bucketItem and collection, and
vacayPlan with its holiday calendars. collections.listMine,
collections.get and vacay.mine return envelopes around rows; an entity
with a null table describes those, so their rows are reached through the
same nesting. Every list holds exactly the keys each read returned
before, so no plugin loses a field. What stays readModel is built field
by field from named columns, and each such entry says what it returns:
setBagMembers, atlas.visited, collections.savePlace and copyToTrip, the
two vacay toggles and journal.addEntryPhoto.

The schema test now covers every child and new table, and calls each
newly shaped method on the real services, comparing nested rows key for
key so the only field allowed to disappear is a withheld one. A further
case proves the nested rows it compares are not empty. gen:plugin-facts
publishes PLUGIN_ENTITY_NESTED to the SDK, and the SDK README, the plugin
wiki pages and server/CLAUDE.md describe the nested rows and list the new
entities.
This commit is contained in:
Maurice
2026-10-08 18:02:39 +02:00
parent 0c9e1cd639
commit 9dec36995e
12 changed files with 973 additions and 192 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ Because the package ships standalone it cannot import server code, so several fi
| SDK copy | Source of truth | Parity test |
|---|---|---|
| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog, `KNOWN_ADDONS`, the output contract `PLUGIN_ENTITY_FIELDS` / `PLUGIN_METHOD_RESULT`) | `server/src/nest/plugins/protocol/envelope.ts`, `protocol/output-contract.ts` and `server/src/addons.ts` via `server/scripts/gen-plugin-facts.ts` — **machine-written, never hand-edit** | server `check:plugin-facts` CI gate; the entity interfaces in `src/index.ts` are type-checked against `PLUGIN_ENTITY_FIELDS` |
| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog, `KNOWN_ADDONS`, the output contract `PLUGIN_ENTITY_FIELDS` / `PLUGIN_ENTITY_NESTED` / `PLUGIN_METHOD_RESULT`) | `server/src/nest/plugins/protocol/envelope.ts`, `protocol/output-contract.ts` and `server/src/addons.ts` via `server/scripts/gen-plugin-facts.ts` (**machine-written, never hand-edit**) | server `check:plugin-facts` CI gate; the entity interfaces in `src/index.ts` are type-checked against `PLUGIN_ENTITY_FIELDS` |
| `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` |
| `src/manifest.ts` (validation rules incl. the `trek` version range) | `server/src/nest/plugins/install/` | kept in sync by hand |
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; `TREK_PLUGINS_REPO` points at a checkout) |
+34 -20
View File
@@ -132,25 +132,39 @@ ungranted call throws `PERMISSION_DENIED`.
Every ctx method falls into one of three kinds, listed per method in the exported
`PLUGIN_METHOD_RESULT`:
- **An entity** (`trip`, `place`, `day`, `reservation`, `packingItem`, `tripFile`,
`budgetItem`, `assignment`, `user`; a list where the entry ends in `[]`). Each row
carries exactly the fields in `PLUGIN_ENTITY_FIELDS[entity]` and no others: the
table's published columns in snake_case, then what TREK adds on top (for a trip
`day_count`, `place_count`, `is_owner`, `owner_username` and `shared_count`; for a
place `category`, `tags` and `ratings`; for a reservation `endpoints`, `travelers`
and `day_positions`; for a day its `assignments` and `notes_items`). A field is only
present when the method's read produces it, so `trips.getById` has no `day_count`
while `trips.listMine` does. Credentials are never part of a row (a trip's
calendar `feed_token`, any account data beyond a user's id, username, display name
and avatar). A column TREK adds later is not delivered until TREK publishes it in
this list, so a row never changes shape behind your back. The `Trip`, `Place`, ...
interfaces name the commonly used fields and are checked against these lists when
the SDK is built; `Place.day_id` is deprecated and never delivered, since a place's
days are the `assignments` of `ctx.trips.getDays()`.
- **`readModel`**: the app's own read model for that data (tags, to-dos, collab
notes, journal entries, bags, collections, accommodations and the like), passed on
as TREK's REST route returns it. These are typed `unknown`; treat a field you did
not check for as optional.
- **An entity** (a list where the entry ends in `[]`). The trip data: `trip`,
`place`, `day`, `reservation`, `accommodation`, `packingItem`, `packingBag`,
`tripFile`, `fileLink`, `budgetItem`, `assignment`, `dayNote`, `todo` and `user`;
the collab board: `collabNote`, `collabPoll` and `collabMessage`; the acting
user's own data: `tag`, `category`, `journey`, `journalEntry`, `bucketItem` and
`collection`. Each row carries exactly the fields in `PLUGIN_ENTITY_FIELDS[entity]`
and no others: the table's published columns in snake_case, then what TREK adds on
top (for a trip `day_count`, `place_count`, `is_owner`, `owner_username` and
`shared_count`; for a place `category`, `tags` and `ratings`; for a reservation
`endpoints`, `travelers` and `day_positions`; for a day its `assignments` and
`notes_items`). A field is only present when the method's read produces it, so
`trips.getById` has no `day_count` while `trips.listMine` does. Credentials are
never part of a row (a trip's calendar `feed_token`, any account data beyond a
user's id, username, display name and avatar). A column TREK adds later is not
delivered until TREK publishes it in this list, so a row never changes shape behind
your back.
The same holds for the rows inside a row. `PLUGIN_ENTITY_NESTED` names, per entity,
the fields that hold rows of another entity (a day's `notes_items` are `dayNote`
rows, a reservation's `endpoints` are `reservationEndpoint` rows), and those carry
that entity's fields and no others, however deep they sit. Every other added field
(counts, `{ user_id, username }` lists, id lists, a place's `category`) is a value
TREK builds field by field. Three results are envelopes around rows rather than a
row: `collections.listMine` (`collectionListing`), `collections.get`
(`collectionDetail`) and `vacay.mine` (`vacayPlanData`).
The `Trip`, `Place`, ... interfaces name the commonly used fields and are checked
against these lists when the SDK is built; `Place.day_id` is deprecated and never
delivered, since a place's days are the `assignments` of `ctx.trips.getDays()`.
- **`readModel`**: a small result TREK builds field by field from named columns, so
no stored row passes through it whole: a bag's members, the Atlas `visited` codes,
what a Vacay toggle did, a collection's saved place or copy summary, and a journal
photo. These are typed `unknown`; treat a field you did not check for as optional.
- **`host`**: a value the host builds itself (`{ deleted }`, `{ sent }`, a model's
answer, an access token) or data you own (`ctx.db`, `ctx.meta`, another plugin's
answer), returned as it is.
@@ -375,7 +389,7 @@ npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign # or just answ
- `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`).
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
- `PLUGIN_ENTITY_FIELDS`, `PLUGIN_METHOD_RESULT`: what each ctx method returns and which fields each entity row carries (see [What a ctx call returns](#what-a-ctx-call-returns)).
- `PLUGIN_ENTITY_FIELDS`, `PLUGIN_ENTITY_NESTED`, `PLUGIN_METHOD_RESULT`: what each ctx method returns, which fields each entity row carries and which of them hold rows of another entity (see [What a ctx call returns](#what-a-ctx-call-returns)).
- `validateManifest(json)` — the manifest rules the server loader uses.
- `settingDefaults(manifest, scope)` — the `default`s of one settings scope, keyed by field: what the host resolves for an unset field, and what `dev` seeds `ctx.config` / `ctx.settings` with. Spread it under your own fixtures when calling `createMockHost` to mirror the host. `SETTING_FIELD_KEYS` is the attribute list the host stores.
- `createMockHost(opts)` (from `trek-plugin-sdk/testing`).
+319 -36
View File
@@ -622,8 +622,291 @@ export const PLUGIN_ENTITY_FIELDS = {
'display_name',
'avatar',
],
dayNote: [
'id',
'day_id',
'trip_id',
'text',
'time',
'icon',
'sort_order',
'created_at',
'color',
],
reservationEndpoint: [
'id',
'reservation_id',
'role',
'sequence',
'name',
'code',
'lat',
'lng',
'timezone',
'local_time',
'local_date',
'created_at',
],
tag: [
'id',
'user_id',
'name',
'color',
'created_at',
],
todo: [
'id',
'trip_id',
'name',
'checked',
'category',
'sort_order',
'due_date',
'description',
'assigned_user_id',
'priority',
'created_at',
'reminded_at',
],
accommodation: [
'id',
'trip_id',
'place_id',
'start_day_id',
'end_day_id',
'check_in',
'check_in_end',
'check_out',
'confirmation',
'notes',
'created_at',
'place_name',
'place_address',
'place_image',
'place_lat',
'place_lng',
'reservation_title',
],
packingBag: [
'id',
'trip_id',
'name',
'color',
'weight_limit_grams',
'sort_order',
'created_at',
'user_id',
'assigned_username',
'members',
'total_weight_grams',
],
fileLink: [
'id',
'file_id',
'reservation_id',
'assignment_id',
'place_id',
'created_at',
'budget_item_id',
],
collabNote: [
'id',
'trip_id',
'user_id',
'category',
'title',
'content',
'color',
'pinned',
'created_at',
'updated_at',
'website',
'username',
'avatar',
'avatar_url',
'attachments',
],
collabPoll: [
'id',
'trip_id',
'user_id',
'question',
'options',
'multiple',
'closed',
'deadline',
'created_at',
'username',
'avatar',
'avatar_url',
'is_closed',
'multiple_choice',
],
collabMessage: [
'id',
'trip_id',
'user_id',
'text',
'reply_to',
'created_at',
'deleted',
'username',
'avatar',
'reply_text',
'reply_username',
'user_avatar',
'avatar_url',
'reactions',
'attachments',
],
journey: [
'id',
'user_id',
'title',
'subtitle',
'cover_gradient',
'status',
'created_at',
'updated_at',
'cover_image',
'show_trip_tracks',
'show_verdict',
'show_mood',
'show_weather',
'status_override',
'photo_location',
'entry_count',
'photo_count',
'place_count',
'trip_date_min',
'trip_date_max',
],
journalEntry: [
'id',
'journey_id',
'source_trip_id',
'source_place_id',
'author_id',
'type',
'title',
'story',
'entry_date',
'entry_time',
'location_name',
'location_lat',
'location_lng',
'mood',
'weather',
'tags',
'visibility',
'sort_order',
'created_at',
'updated_at',
'pros_cons',
'stats_excluded',
'dismissed',
'country_code',
'source_assignment_id',
'is_draft',
'photos',
'source_trip_name',
],
category: [
'id',
'name',
'color',
'icon',
'user_id',
'created_at',
],
bucketItem: [
'id',
'user_id',
'name',
'lat',
'lng',
'country_code',
'notes',
'created_at',
'target_date',
'visited_at',
'visited_source',
'region_code',
],
collection: [
'id',
'owner_id',
'name',
'description',
'color',
'icon',
'cover_image',
'links',
'sort_order',
'created_at',
'updated_at',
'place_count',
'members',
'is_owner',
'labels',
],
collectionListing: [
'collections',
'incomingInvites',
],
collectionDetail: [
'collection',
'places',
],
vacayPlan: [
'id',
'owner_id',
'block_weekends',
'holidays_enabled',
'holidays_region',
'school_holidays_enabled',
'company_holidays_enabled',
'carry_over_enabled',
'created_at',
'weekend_days',
'week_start',
'holiday_calendars',
],
vacayHolidayCalendar: [
'id',
'plan_id',
'type',
'region',
'label',
'color',
'sort_order',
],
vacayPlanData: [
'plan',
'users',
'pendingInvites',
'incomingInvites',
'isOwner',
'isFused',
],
} as const;
/**
* The fields of an entity that hold rows of another entity (one row or a list),
* and which one. Those rows carry that entity's PLUGIN_ENTITY_FIELDS and no others,
* however deep they sit, so a column TREK adds to a child table is held back the
* same way. A field not listed here holds a value TREK builds field by field.
*/
export const PLUGIN_ENTITY_NESTED: Readonly<Record<string, Readonly<Record<string, string>>>> = {
place: { tags: 'tag' },
day: { assignments: 'assignment', notes_items: 'dayNote' },
reservation: { endpoints: 'reservationEndpoint' },
collectionListing: { collections: 'collection' },
collectionDetail: { collection: 'collection' },
vacayPlan: { holiday_calendars: 'vacayHolidayCalendar' },
vacayPlanData: { plan: 'vacayPlan' },
};
/**
* What each ctx method returns: an entity from PLUGIN_ENTITY_FIELDS (`trip`, or
* `trip[]` for a list), `host` for a value the host builds itself or data the
@@ -639,54 +922,54 @@ export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
'trips.getPlaces': 'place[]',
'trips.getReservations': 'reservation[]',
'trips.getDays': 'day[]',
'trips.getAccommodations': 'readModel',
'trips.getAccommodations': 'accommodation[]',
'trips.listMine': 'trip[]',
'reservations.listMine': 'reservation[]',
'reservations.create': 'reservation',
'reservations.update': 'reservation',
'reservations.delete': 'host',
'accommodations.create': 'readModel',
'accommodations.update': 'readModel',
'accommodations.create': 'accommodation',
'accommodations.update': 'accommodation',
'accommodations.delete': 'host',
'packing.list': 'packingItem[]',
'packing.create': 'packingItem',
'packing.update': 'packingItem',
'packing.delete': 'host',
'packing.listBags': 'readModel',
'packing.createBag': 'readModel',
'packing.updateBag': 'readModel',
'packing.listBags': 'packingBag[]',
'packing.createBag': 'packingBag',
'packing.updateBag': 'packingBag',
'packing.deleteBag': 'host',
'packing.setBagMembers': 'readModel',
'files.list': 'tripFile[]',
'files.getContent': 'host',
'files.create': 'tripFile',
'files.createLink': 'readModel',
'files.createLink': 'fileLink[]',
'files.update': 'tripFile',
'files.softDelete': 'host',
'collab.listNotes': 'readModel',
'collab.listPolls': 'readModel',
'collab.listMessages': 'readModel',
'collab.createNote': 'readModel',
'collab.createPoll': 'readModel',
'collab.votePoll': 'readModel',
'collab.createMessage': 'readModel',
'collab.listNotes': 'collabNote[]',
'collab.listPolls': 'collabPoll[]',
'collab.listMessages': 'collabMessage[]',
'collab.createNote': 'collabNote',
'collab.createPoll': 'collabPoll',
'collab.votePoll': 'collabPoll',
'collab.createMessage': 'collabMessage',
'trips.addMember': 'host',
'trips.removeMember': 'host',
'trips.create': 'trip',
'journal.listMine': 'readModel',
'journal.getEntries': 'readModel',
'journal.listMine': 'journey[]',
'journal.getEntries': 'journalEntry[]',
'atlas.visited': 'readModel',
'atlas.bucketList': 'readModel',
'atlas.bucketList': 'bucketItem[]',
'rates.get': 'host',
'vacay.mine': 'readModel',
'daynotes.list': 'readModel',
'daynotes.create': 'readModel',
'daynotes.update': 'readModel',
'vacay.mine': 'vacayPlanData',
'daynotes.list': 'dayNote[]',
'daynotes.create': 'dayNote',
'daynotes.update': 'dayNote',
'daynotes.delete': 'host',
'collections.listMine': 'readModel',
'collections.get': 'readModel',
'collections.create': 'readModel',
'collections.update': 'readModel',
'collections.listMine': 'collectionListing',
'collections.get': 'collectionDetail',
'collections.create': 'collection',
'collections.update': 'collection',
'collections.savePlace': 'readModel',
'collections.copyToTrip': 'readModel',
'collections.deletePlace': 'host',
@@ -694,26 +977,26 @@ export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
'atlas.unmarkCountry': 'host',
'atlas.markRegion': 'host',
'atlas.unmarkRegion': 'host',
'atlas.createBucketItem': 'readModel',
'atlas.createBucketItem': 'bucketItem',
'atlas.deleteBucketItem': 'host',
'vacay.toggleEntry': 'readModel',
'vacay.toggleCompanyHoliday': 'readModel',
'journal.createEntry': 'readModel',
'journal.createEntry': 'journalEntry',
'journal.addEntryPhoto': 'readModel',
'journal.updateEntry': 'readModel',
'journal.updateEntry': 'journalEntry',
'journal.deleteEntry': 'host',
'journal.createJourney': 'readModel',
'journal.createJourney': 'journey',
'journal.deleteJourney': 'host',
'weather.get': 'host',
'categories.list': 'readModel',
'tags.list': 'readModel',
'tags.create': 'readModel',
'tags.update': 'readModel',
'categories.list': 'category[]',
'tags.list': 'tag[]',
'tags.create': 'tag',
'tags.update': 'tag',
'tags.delete': 'host',
'trips.members': 'user[]',
'todos.list': 'readModel',
'todos.create': 'readModel',
'todos.update': 'readModel',
'todos.list': 'todo[]',
'todos.create': 'todo',
'todos.update': 'todo',
'todos.delete': 'host',
'costs.getByTrip': 'budgetItem[]',
'costs.listMine': 'budgetItem[]',
+3 -2
View File
@@ -1007,8 +1007,9 @@ export {
export {
EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS,
} from './generated/host-facts.js';
// What ctx results carry: the published fields of each entity, and what each method returns.
export { PLUGIN_ENTITY_FIELDS, PLUGIN_METHOD_RESULT } from './generated/host-facts.js';
// What ctx results carry: the published fields of each entity, the fields holding rows
// of another entity, and what each method returns.
export { PLUGIN_ENTITY_FIELDS, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_RESULT } from './generated/host-facts.js';
// The lucide icons a `capabilities.poiCategories` entry may use, and the per-plugin cap.
export { POI_CATEGORY_ICONS, POI_CATEGORY_MAX } from './generated/host-facts.js';
+17 -1
View File
@@ -5,7 +5,7 @@
* runtime side: what the exports hold and that they cover the method vocabulary.
*/
import { describe, it, expect } from 'vitest';
import { PLUGIN_ENTITY_FIELDS, PLUGIN_METHOD_RESULT } from '../src/index.js';
import { PLUGIN_ENTITY_FIELDS, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_RESULT } from '../src/index.js';
import { KNOWN_ADDONS } from '../src/manifest.js';
import { KNOWN_METHODS } from '../src/generated/host-facts.js';
@@ -26,6 +26,20 @@ describe('PLUGIN_ENTITY_FIELDS', () => {
});
});
describe('PLUGIN_ENTITY_NESTED', () => {
it('points the fields that hold rows at entities with a field list', () => {
const fields: Readonly<Record<string, readonly string[]>> = PLUGIN_ENTITY_FIELDS;
for (const [entity, nested] of Object.entries(PLUGIN_ENTITY_NESTED)) {
for (const [field, child] of Object.entries(nested)) {
expect(fields[entity]).toContain(field);
expect(fields[child]).toBeDefined();
}
}
expect(PLUGIN_ENTITY_NESTED.day).toEqual({ assignments: 'assignment', notes_items: 'dayNote' });
expect(PLUGIN_ENTITY_NESTED.reservation).toEqual({ endpoints: 'reservationEndpoint' });
});
});
describe('PLUGIN_METHOD_RESULT', () => {
it('describes every wire method plus the three unconditional ones', () => {
for (const method of KNOWN_METHODS) expect(PLUGIN_METHOD_RESULT[method]).toBeDefined();
@@ -41,6 +55,8 @@ describe('PLUGIN_METHOD_RESULT', () => {
expect(PLUGIN_METHOD_RESULT['trips.getById']).toBe('trip');
expect(PLUGIN_METHOD_RESULT['trips.getPlaces']).toBe('place[]');
expect(PLUGIN_METHOD_RESULT['db.query']).toBe('host');
expect(PLUGIN_METHOD_RESULT['tags.list']).toBe('tag[]');
expect(PLUGIN_METHOD_RESULT['atlas.visited']).toBe('readModel');
});
});
+1 -1
View File
@@ -48,7 +48,7 @@ Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`c
- **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; `src/websocket.ts` is a re-export stub for old call sites. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
- **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `<domain>.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change.
- **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator**: it bypasses that gate. Every tick first takes the job's row in `scheduler_leases` (`SchedulerLeasesRepository.acquire` in one `UnitOfWork` transaction, where a conditional UPDATE decides the race), renews it while it runs and keeps it for a short settle window afterwards, so two processes on one database never run the same tick; a job needs nothing of its own for this. Claims on individual rows (the reminder jobs' `claimReminder`) stay where they are: the lease decides who runs a tick, the claim what a tick may send.
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity read (`trips.getById`, `costs.listMine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, so a new column on `trips`, `places`, `reservations` and the other whole-row tables fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` is generated from it). A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap and a time budget checked between rows. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest**; it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/`: run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity result (`trips.getById`, `todos.list`, `vacay.mine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, together with every child row it carries whose field is listed in `PLUGIN_ENTITY_NESTED` (a day's `notes_items`, a reservation's `endpoints`). A new column on any whole-row table behind those entities, child tables included, fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` and `PLUGIN_ENTITY_NESTED` are generated from it); a method left as `readModel` must return a value built field by field from named columns. A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap and a time budget checked between rows. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
- **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. Where the data lives (`data/`, `uploads/`, backups, logs, key files, the DB file) is resolved in one place, `src/app-config/data-paths.ts`; a provider that reads one injects `DataPathsService`, plain helper modules shared with code outside the container call `resolveDataPaths()` (the list is in `src/app-config/README.md`), and nothing anchors a data path on its own `__dirname` or on `process.cwd()`.
- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only holds the connection and applies pragmas. It does not build a schema, and importing it opens nothing (the first reader does). The connection's lifecycle belongs to the `DatabaseLifecycle` provider (`nest/database/database-lifecycle.service.ts`): `buildApp()` opens it through `open()` (which binds the ORM to later swaps and runs the schema bootstrap below), the shutdown closes it through `close()`, and a restore or the demo reset swaps the file only through the database backup port, `DATABASE_BACKUP` (`nest/database/database-backup.interface.ts`; the SQLite implementation is `nest/backup/sqlite-database-backup.ts`: `VACUUM INTO` snapshot, close, atomic file swap, `reopen()`). The port works on `DatabaseLifecycle.file`, the one resolved path (`TREK_DB_FILE` or `data/travel.db`), and a snapshot that fails fails the backup rather than archiving the live file. `MaintenanceRepository` is provided only by `MaintenanceModule`'s factory and injected everywhere else; no consumer constructs it. A provider that has to follow every reopen (the demo reset job saves the first demo baseline after a restore's re-bootstrap seeded the example trips) registers with `DatabaseLifecycle.onReopened()`, which runs after the reopen's schema bootstrap; a listener contains its own failures, since one that throws fails the reopen. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests**: the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. An install the retired positional runner migrated (a `schema_version` row, no `mikro_orm_migrations` rows) is baselined by `db/legacy-baseline.ts`: the migrations whose `Legacy migration step N` docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's `createTables()` did on every boot; anything it cannot map refuses the boot. Before a boot applies pending migrations to a file database that holds anything, `db/pre-migrate-snapshot.ts` writes a `VACUUM INTO` copy next to it (`pre-migrate-<schema>-<time>.db`, `<schema>` being `legacy-<schema_version>` or the last recorded migration's timestamp; the newest `TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP` stay) and the newer-database refusal names the matching copy; a copy that cannot be written refuses the boot unless `TREK_DB_PRE_MIGRATE_SNAPSHOT=false`. It is off under `NODE_ENV=test` unless a suite sets that variable to true, and a restore's re-bootstrap skips it (the archive is the copy). After the migrator, `db/email-case-index.ts` creates the case-insensitive unique index on `users.email` that its migration had to defer on an install holding two accounts whose emails differ only in case (flag `email_case_index_pending`). `db/orm-driver.ts` binds the ORM to that same connection instead of opening a second one — with `:memory:` under test, a second connection is a second, empty database. Kysely statements (`this.kysely<...>()`) type against `DB` from `db/kysely/db.ts`, narrowed with `Pick<DB, 'trips' | 'days'>`; those files are generated from the migrated schema by `scripts/generate-db-types.ts`, so a migration that adds or changes a column is followed by `npm run gen:db-types`, never by a hand-written table interface. Every read and write goes through a repository in `db/repositories/` (each extends the shared `TrekRepository` base in `db/repositories/_shared/`, which validates the request context before delegating to MikroORM) and, for multi-statement writes, `UnitOfWork.transactional(...)` (`nest/database/unit-of-work.ts`, provided by the global `OrmModule`) — there is no `DatabaseService`/`DatabaseModule` to inject any more. Entrypoints outside an HTTP request — an MCP tool call, a plugin RPC dispatch, a WebSocket message, a cron job, boot-time seeding, a backup restore — open one explicitly with `withRequestContext()` (`nest/database/request-context.ts`), since only the pathless `mikroOrmRequestContext` middleware `buildApp()` mounts (with `@mikro-orm/nestjs`'s own `registerRequestContext` turned off in `app.module.ts`, UPLOADS-P16) forks an `EntityManager` per HTTP request. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker, opened from a snapshot `tests/global-setup.ts` migrates once per run; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
+19 -1
View File
@@ -24,7 +24,8 @@ import {
} from '../src/nest/plugins/protocol/envelope';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
import {
PLUGIN_ENTITY_CONTRACT, PLUGIN_METHOD_OUTPUT, pluginEntityFields, type PluginEntityName, type PluginMethodOutput,
PLUGIN_ENTITY_CONTRACT, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_OUTPUT, pluginEntityFields, type PluginEntityName,
type PluginMethodOutput,
} from '../src/nest/plugins/protocol/output-contract';
import { ADDON_IDS } from '../src/addons';
// A relative import of the shared SOURCE, not @trek/shared: the plugin-facts CI job
@@ -68,6 +69,13 @@ const ENTITY_FIELDS = (Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[]
.map((entity) => ` ${entity}: [\n${fieldList(pluginEntityFields(entity))}\n ],`)
.join('\n');
const ENTITY_NESTED = (Object.keys(PLUGIN_ENTITY_NESTED) as PluginEntityName[])
.map((entity) => {
const children = Object.entries(PLUGIN_ENTITY_NESTED[entity] ?? {}).map(([key, child]) => `${key}: '${child}'`);
return ` ${entity}: { ${children.join(', ')} },`;
})
.join('\n');
const METHOD_RESULT = Object.fromEntries(
Object.entries(PLUGIN_METHOD_OUTPUT).map(([method, output]) => [method, describeOutput(output)]),
);
@@ -138,6 +146,16 @@ export const PLUGIN_ENTITY_FIELDS = {
${ENTITY_FIELDS}
} as const;
/**
* The fields of an entity that hold rows of another entity (one row or a list),
* and which one. Those rows carry that entity's PLUGIN_ENTITY_FIELDS and no others,
* however deep they sit, so a column TREK adds to a child table is held back the
* same way. A field not listed here holds a value TREK builds field by field.
*/
export const PLUGIN_ENTITY_NESTED: Readonly<Record<string, Readonly<Record<string, string>>>> = {
${ENTITY_NESTED}
};
/**
* What each ctx method returns: an entity from PLUGIN_ENTITY_FIELDS (\`trip\`, or
* \`trip[]\` for a list), \`host\` for a value the host builds itself or data the
@@ -9,19 +9,31 @@
* received when the contract was drawn, minus the credentials, and a new column stays
* on the host until someone adds it here on purpose.
*
* The plugin SDK publishes the same lists (`PLUGIN_ENTITY_FIELDS`), generated from
* this file by `server/scripts/gen-plugin-facts.ts`, and type-checks its own entity
* interfaces against them. `tests/unit/plugins/output-contract.schema.test.ts` holds
* the column lists to the migrated schema, so a new column fails CI until it is
* classified as published or withheld.
* The allowlist reaches into the rows a result carries as well. A derived key that
* holds rows of another table (a day's `notes_items`, a reservation's `endpoints`)
* names that child's entity in PLUGIN_ENTITY_NESTED, and the child is cut down to its
* own field list the same way, however deep it sits. A derived key without an entry
* there holds a value the host builds field by field (a count, `{ user_id, username }`
* pairs, an id list), which cannot pick up a new column on its own.
*
* The plugin SDK publishes the same lists (`PLUGIN_ENTITY_FIELDS`,
* `PLUGIN_ENTITY_NESTED`), generated from this file by
* `server/scripts/gen-plugin-facts.ts`, and type-checks its own entity interfaces
* against them. `tests/unit/plugins/output-contract.schema.test.ts` holds the column
* lists to the migrated schema, so a new column fails CI until it is classified as
* published or withheld.
*
* Pure data and one pure function, like envelope.ts: no imports with side effects.
*/
import type { KnownMethod, UnconditionalMethod } from './envelope';
export interface PluginEntityContract {
/** The table the rows are read from. */
readonly table: string;
/**
* The table the rows are read from, or null for an envelope: an object the host
* assembles around rows (`{ collections, incomingInvites }`), whose keys are all
* `derived`.
*/
readonly table: string | null;
/** The table's columns a plugin receives, in the order the row carries them. */
readonly columns: readonly string[];
/** Columns a plugin never receives: credentials, whichever read path carries them. */
@@ -126,7 +138,7 @@ export const PLUGIN_ENTITY_CONTRACT = {
'leg_transport_mode', 'incoming_leg_transport_mode', 'route_excluded', 'accommodation_id', 'created_at',
],
withheld: [],
// The assignment read model names its fields one by one.
// The assignment read model names its fields one by one, its `place` included.
wholeRow: false,
derived: ['participants', 'tour_place_id', 'tour_route_geometry', 'place'],
},
@@ -139,10 +151,208 @@ export const PLUGIN_ENTITY_CONTRACT = {
wholeRow: false,
derived: [],
},
// A day's `notes_items` and the daynotes.* results.
dayNote: {
table: 'day_notes',
columns: ['id', 'day_id', 'trip_id', 'text', 'time', 'icon', 'sort_order', 'created_at', 'color'],
withheld: [],
wholeRow: true,
derived: [],
},
// A reservation's `endpoints`: the stops of a flight, train or car booking.
reservationEndpoint: {
table: 'reservation_endpoints',
columns: [
'id', 'reservation_id', 'role', 'sequence', 'name', 'code', 'lat', 'lng', 'timezone', 'local_time',
'local_date', 'created_at',
],
withheld: [],
wholeRow: true,
derived: [],
},
// The tags.* results and a place's `tags` (whose read names its columns, without
// `user_id` on the compact projection).
tag: {
table: 'tags',
columns: ['id', 'user_id', 'name', 'color', 'created_at'],
withheld: [],
wholeRow: true,
derived: [],
},
todo: {
table: 'todo_items',
columns: [
'id', 'trip_id', 'name', 'checked', 'category', 'sort_order', 'due_date', 'description', 'assigned_user_id',
'priority', 'created_at', 'reminded_at',
],
withheld: [],
wholeRow: true,
derived: [],
},
// trips.getAccommodations and accommodations.create/update.
accommodation: {
table: 'day_accommodations',
columns: [
'id', 'trip_id', 'place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out',
'confirmation', 'notes', 'created_at',
],
withheld: [],
wholeRow: true,
derived: ['place_name', 'place_address', 'place_image', 'place_lat', 'place_lng', 'reservation_title'],
},
packingBag: {
table: 'packing_bags',
columns: ['id', 'trip_id', 'name', 'color', 'weight_limit_grams', 'sort_order', 'created_at', 'user_id'],
withheld: [],
wholeRow: true,
derived: ['assigned_username', 'members', 'total_weight_grams'],
},
// files.createLink: the file's links after the new one was added.
fileLink: {
table: 'file_links',
columns: ['id', 'file_id', 'reservation_id', 'assignment_id', 'place_id', 'created_at', 'budget_item_id'],
withheld: [],
wholeRow: true,
derived: [],
},
collabNote: {
table: 'collab_notes',
columns: [
'id', 'trip_id', 'user_id', 'category', 'title', 'content', 'color', 'pinned', 'created_at', 'updated_at',
'website',
],
withheld: [],
wholeRow: true,
derived: ['username', 'avatar', 'avatar_url', 'attachments'],
},
// `options` is the column, delivered decoded with each option's voters.
collabPoll: {
table: 'collab_polls',
columns: ['id', 'trip_id', 'user_id', 'question', 'options', 'multiple', 'closed', 'deadline', 'created_at'],
withheld: [],
wholeRow: true,
derived: ['username', 'avatar', 'avatar_url', 'is_closed', 'multiple_choice'],
},
collabMessage: {
table: 'collab_messages',
columns: ['id', 'trip_id', 'user_id', 'text', 'reply_to', 'created_at', 'deleted'],
withheld: [],
wholeRow: true,
derived: [
'username', 'avatar', 'reply_text', 'reply_username', 'user_avatar', 'avatar_url', 'reactions', 'attachments',
],
},
journey: {
table: 'journeys',
columns: [
'id', 'user_id', 'title', 'subtitle', 'cover_gradient', 'status', 'created_at', 'updated_at', 'cover_image',
'show_trip_tracks', 'show_verdict', 'show_mood', 'show_weather', 'status_override', 'photo_location',
],
withheld: [],
wholeRow: true,
derived: ['entry_count', 'photo_count', 'place_count', 'trip_date_min', 'trip_date_max'],
},
// `tags` and `pros_cons` are the columns, delivered decoded from their JSON.
journalEntry: {
table: 'journey_entries',
columns: [
'id', 'journey_id', 'source_trip_id', 'source_place_id', 'author_id', 'type', 'title', 'story', 'entry_date',
'entry_time', 'location_name', 'location_lat', 'location_lng', 'mood', 'weather', 'tags', 'visibility',
'sort_order', 'created_at', 'updated_at', 'pros_cons', 'stats_excluded', 'dismissed', 'country_code',
'source_assignment_id', 'is_draft',
],
withheld: [],
wholeRow: true,
derived: ['photos', 'source_trip_name'],
},
category: {
table: 'categories',
columns: ['id', 'name', 'color', 'icon', 'user_id', 'created_at'],
withheld: [],
wholeRow: true,
derived: [],
},
bucketItem: {
table: 'bucket_list',
columns: [
'id', 'user_id', 'name', 'lat', 'lng', 'country_code', 'notes', 'created_at', 'target_date', 'visited_at',
'visited_source', 'region_code',
],
withheld: [],
wholeRow: true,
derived: [],
},
// `links` is the column, delivered decoded.
collection: {
table: 'collections',
columns: [
'id', 'owner_id', 'name', 'description', 'color', 'icon', 'cover_image', 'links', 'sort_order', 'created_at',
'updated_at',
],
withheld: [],
wholeRow: true,
derived: ['place_count', 'members', 'is_owner', 'labels'],
},
// collections.listMine. The invites are built field by field.
collectionListing: {
table: null,
columns: [],
withheld: [],
wholeRow: false,
derived: ['collections', 'incomingInvites'],
},
// collections.get. The saved places name their columns one by one.
collectionDetail: {
table: null,
columns: [],
withheld: [],
wholeRow: false,
derived: ['collection', 'places'],
},
vacayPlan: {
table: 'vacay_plans',
columns: [
'id', 'owner_id', 'block_weekends', 'holidays_enabled', 'holidays_region', 'school_holidays_enabled',
'company_holidays_enabled', 'carry_over_enabled', 'created_at', 'weekend_days', 'week_start',
],
withheld: [],
wholeRow: true,
derived: ['holiday_calendars'],
},
vacayHolidayCalendar: {
table: 'vacay_holiday_calendars',
columns: ['id', 'plan_id', 'type', 'region', 'label', 'color', 'sort_order'],
withheld: [],
wholeRow: true,
derived: [],
},
// vacay.mine. The users and the invites are built from named columns.
vacayPlanData: {
table: null,
columns: [],
withheld: [],
wholeRow: false,
derived: ['plan', 'users', 'pendingInvites', 'incomingInvites', 'isOwner', 'isFused'],
},
} as const satisfies Record<string, PluginEntityContract>;
export type PluginEntityName = keyof typeof PLUGIN_ENTITY_CONTRACT;
/**
* The derived keys that hold rows of another entity, per entity: the value (one row or
* a list of rows) is cut down to that entity's own fields, recursively. Every key here
* is one of the entity's `derived` keys (a unit test holds that).
*/
export const PLUGIN_ENTITY_NESTED: { readonly [E in PluginEntityName]?: Readonly<Record<string, PluginEntityName>> } = {
place: { tags: 'tag' },
day: { assignments: 'assignment', notes_items: 'dayNote' },
reservation: { endpoints: 'reservationEndpoint' },
collectionListing: { collections: 'collection' },
collectionDetail: { collection: 'collection' },
vacayPlan: { holiday_calendars: 'vacayHolidayCalendar' },
vacayPlanData: { plan: 'vacayPlan' },
};
/**
* The output of one RPC method.
*
@@ -151,8 +361,9 @@ export type PluginEntityName = keyof typeof PLUGIN_ENTITY_CONTRACT;
* a token) or data the plugin owns (its own database, its metadata, a peer's
* answer). There is no stored row behind it to leak.
* - `readModel`: a domain read model shared with the REST route, passed through as
* the app returns it. The SDK types these results as `unknown`; they are the next
* candidates for an entity contract.
* the app returns it. Each one is built field by field from named columns (see the
* comments on the entries below), so no stored row passes through whole and a new
* column cannot reach it by itself. The SDK types these results as `unknown`.
*/
export type PluginMethodOutput =
| { readonly kind: 'entity'; readonly entity: PluginEntityName; readonly many: boolean }
@@ -174,81 +385,88 @@ export const PLUGIN_METHOD_OUTPUT = {
'trips.getPlaces': rows('place'),
'trips.getReservations': rows('reservation'),
'trips.getDays': rows('day'),
'trips.getAccommodations': READ_MODEL,
'trips.getAccommodations': rows('accommodation'),
'trips.listMine': rows('trip'),
'reservations.listMine': rows('reservation'),
'reservations.create': row('reservation'),
'reservations.update': row('reservation'),
'reservations.delete': HOST,
'accommodations.create': READ_MODEL,
'accommodations.update': READ_MODEL,
'accommodations.create': row('accommodation'),
'accommodations.update': row('accommodation'),
'accommodations.delete': HOST,
'packing.list': rows('packingItem'),
'packing.create': row('packingItem'),
'packing.update': row('packingItem'),
'packing.delete': HOST,
'packing.listBags': READ_MODEL,
'packing.createBag': READ_MODEL,
'packing.updateBag': READ_MODEL,
'packing.listBags': rows('packingBag'),
'packing.createBag': row('packingBag'),
'packing.updateBag': row('packingBag'),
'packing.deleteBag': HOST,
// The bag's members: user_id, username and avatar, selected by name.
'packing.setBagMembers': READ_MODEL,
'files.list': rows('tripFile'),
'files.getContent': HOST,
'files.create': row('tripFile'),
'files.createLink': READ_MODEL,
'files.createLink': rows('fileLink'),
'files.update': row('tripFile'),
'files.softDelete': HOST,
'collab.listNotes': READ_MODEL,
'collab.listPolls': READ_MODEL,
'collab.listMessages': READ_MODEL,
'collab.createNote': READ_MODEL,
'collab.createPoll': READ_MODEL,
'collab.votePoll': READ_MODEL,
'collab.createMessage': READ_MODEL,
'collab.listNotes': rows('collabNote'),
'collab.listPolls': rows('collabPoll'),
'collab.listMessages': rows('collabMessage'),
'collab.createNote': row('collabNote'),
'collab.createPoll': row('collabPoll'),
'collab.votePoll': row('collabPoll'),
'collab.createMessage': row('collabMessage'),
'trips.addMember': HOST,
'trips.removeMember': HOST,
'trips.create': row('trip'),
'journal.listMine': READ_MODEL,
'journal.getEntries': READ_MODEL,
'journal.listMine': rows('journey'),
'journal.getEntries': rows('journalEntry'),
// { countries, regions }: country and region codes selected by name.
'atlas.visited': READ_MODEL,
'atlas.bucketList': READ_MODEL,
'atlas.bucketList': rows('bucketItem'),
'rates.get': HOST,
'vacay.mine': READ_MODEL,
'daynotes.list': READ_MODEL,
'daynotes.create': READ_MODEL,
'daynotes.update': READ_MODEL,
'vacay.mine': row('vacayPlanData'),
'daynotes.list': rows('dayNote'),
'daynotes.create': row('dayNote'),
'daynotes.update': row('dayNote'),
'daynotes.delete': HOST,
'collections.listMine': READ_MODEL,
'collections.get': READ_MODEL,
'collections.create': READ_MODEL,
'collections.update': READ_MODEL,
'collections.listMine': row('collectionListing'),
'collections.get': row('collectionDetail'),
'collections.create': row('collection'),
'collections.update': row('collection'),
// { place } with the saved place's named columns, or { duplicate, duplicateOf: { id, name } }.
'collections.savePlace': READ_MODEL,
// { copied, skipped: [{ id, name }] }.
'collections.copyToTrip': READ_MODEL,
'collections.deletePlace': HOST,
'atlas.markCountry': HOST,
'atlas.unmarkCountry': HOST,
'atlas.markRegion': HOST,
'atlas.unmarkRegion': HOST,
'atlas.createBucketItem': READ_MODEL,
'atlas.createBucketItem': row('bucketItem'),
'atlas.deleteBucketItem': HOST,
// { action, fraction, kind }: what the toggle did.
'vacay.toggleEntry': READ_MODEL,
// { action, fraction }: what the toggle did.
'vacay.toggleCompanyHoliday': READ_MODEL,
'journal.createEntry': READ_MODEL,
'journal.createEntry': row('journalEntry'),
// The gallery photo as linked to the entry, its columns selected by name.
'journal.addEntryPhoto': READ_MODEL,
'journal.updateEntry': READ_MODEL,
'journal.updateEntry': row('journalEntry'),
'journal.deleteEntry': HOST,
'journal.createJourney': READ_MODEL,
'journal.createJourney': row('journey'),
'journal.deleteJourney': HOST,
'weather.get': HOST,
'categories.list': READ_MODEL,
'tags.list': READ_MODEL,
'tags.create': READ_MODEL,
'tags.update': READ_MODEL,
'categories.list': rows('category'),
'tags.list': rows('tag'),
'tags.create': row('tag'),
'tags.update': row('tag'),
'tags.delete': HOST,
'trips.members': rows('user'),
'todos.list': READ_MODEL,
'todos.create': READ_MODEL,
'todos.update': READ_MODEL,
'todos.list': rows('todo'),
'todos.create': row('todo'),
'todos.update': row('todo'),
'todos.delete': HOST,
'costs.getByTrip': rows('budgetItem'),
'costs.listMine': rows('budgetItem'),
@@ -288,20 +506,42 @@ export function pluginEntityFields(entity: PluginEntityName): readonly string[]
return [...contract.columns, ...contract.derived];
}
const fieldSets = new Map<PluginEntityName, ReadonlySet<string>>();
function fieldSetOf(entity: PluginEntityName): ReadonlySet<string> {
let fields = fieldSets.get(entity);
if (!fields) {
fields = new Set(pluginEntityFields(entity));
fieldSets.set(entity, fields);
}
return fields;
}
/**
* A copy of `row` holding only the published fields, in the row's own key order.
* Values are passed on untouched. Anything that is not a plain row (null, undefined,
* a primitive) passes through as it is, so "not found" keeps its wire form.
* A copy of `value` holding only the published fields of `entity`, in the row's own
* key order. A field that holds rows of another entity is cut down the same way;
* every other value is passed on untouched. Anything that is not a plain row (null,
* undefined, a primitive, an array) passes through as it is, so "not found" keeps its
* wire form.
*/
function pickFields(value: unknown, fields: ReadonlySet<string>): unknown {
function pickFields(value: unknown, entity: PluginEntityName): unknown {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
const fields = fieldSetOf(entity);
const nested = PLUGIN_ENTITY_NESTED[entity];
const out: Record<string, unknown> = {};
for (const [key, field] of Object.entries(value as Record<string, unknown>)) {
if (fields.has(key)) out[key] = field;
if (!fields.has(key)) continue;
const child = nested?.[key];
out[key] = child ? shapeRows(field, child) : field;
}
return out;
}
/** A child value: one row or a list of rows of `entity`, each cut down to its fields. */
function shapeRows(value: unknown, entity: PluginEntityName): unknown {
return Array.isArray(value) ? value.map((item) => pickFields(item, entity)) : pickFields(value, entity);
}
function outputOf(method: string): PluginMethodOutput | undefined {
return (PLUGIN_METHOD_OUTPUT as Readonly<Record<string, PluginMethodOutput | undefined>>)[method];
}
@@ -315,7 +555,6 @@ export function returnsEntity(method: string): boolean {
export function shapePluginOutput(method: string, result: unknown): unknown {
const output = outputOf(method);
if (output?.kind !== 'entity') return result;
const fields: ReadonlySet<string> = new Set(pluginEntityFields(output.entity));
if (output.many) return Array.isArray(result) ? result.map((item) => pickFields(item, fields)) : result;
return pickFields(result, fields);
if (output.many) return Array.isArray(result) ? result.map((item) => pickFields(item, output.entity)) : result;
return pickFields(result, output.entity);
}
@@ -4,24 +4,44 @@
* Two questions, both answered on the migrated schema and the real domain services:
*
* - Does every column of a whole-row table have a decision? A migration that adds a
* column to `trips` fails here until the column is listed as published or withheld,
* which is the point: a new column no longer reaches plugins by default, and nobody
* forgets to decide.
* column to `trips`, or to a child table such as `day_notes` or
* `reservation_endpoints`, fails here until the column is listed as published or
* withheld, which is the point: a new column no longer reaches plugins by default,
* and nobody forgets to decide.
* - Does the contract drop anything a plugin received before it existed? Each entity
* method is called once directly on its handler and its result is put through the
* contract; the only keys allowed to disappear are the withheld credentials. A new
* key a read model starts carrying fails here the same way a new column does.
* contract; the only keys allowed to disappear, at the top or in a nested child row,
* are the withheld credentials. A new key a read model starts carrying fails here
* the same way a new column does.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createPluginRpcHostParts } from '../../helpers/plugin-host';
import { addTripMember, createBudgetItem, createDayAssignment, createPackingItem, createPlace, createReservation, createTrip, createUser } from '../../helpers/factories';
import {
addTripMember,
createBudgetItem,
createCollabNote,
createDayAccommodation,
createDayAssignment,
createDayNote,
createJourney,
createJourneyEntry,
createPackingItem,
createPlace,
createReservation,
createTag,
createTodoItem,
createTrip,
createUser,
linkTripToJourney,
} from '../../helpers/factories';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
import type { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { KNOWN_PERMISSIONS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import {
PLUGIN_ENTITY_CONTRACT,
PLUGIN_ENTITY_NESTED,
PLUGIN_METHOD_OUTPUT,
shapePluginOutput,
type PluginEntityContract,
@@ -33,41 +53,83 @@ afterAll(() => testDb.close());
const ENTITIES = Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[];
const contractOf = (entity: PluginEntityName): PluginEntityContract => PLUGIN_ENTITY_CONTRACT[entity];
/** The entities read from a table; the envelopes (table null) have no columns to check. */
const TABLE_ENTITIES = ENTITIES.filter((e) => contractOf(e).table !== null);
const tableOf = (entity: PluginEntityName): string => {
const table = contractOf(entity).table;
if (table === null) throw new Error(`${entity} is an envelope, not a table`);
return table;
};
const columnsOf = (table: string): string[] =>
(testDb.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name);
describe('the output contract against the migrated schema', () => {
it.each(ENTITIES)('OUTCONTRACT-SCHEMA-001 %s names only columns its table has', (entity) => {
it.each(TABLE_ENTITIES)('OUTCONTRACT-SCHEMA-001 %s names only columns its table has', (entity) => {
const contract = contractOf(entity);
const columns = columnsOf(contract.table);
const columns = columnsOf(tableOf(entity));
expect(columns.length).toBeGreaterThan(0);
expect(contract.columns.filter((c) => !columns.includes(c))).toEqual([]);
expect(contract.withheld.filter((c) => !columns.includes(c))).toEqual([]);
});
it.each(ENTITIES.filter((e) => contractOf(e).wholeRow))(
it.each(TABLE_ENTITIES.filter((e) => contractOf(e).wholeRow))(
'OUTCONTRACT-SCHEMA-002 every column of %s is published or withheld',
(entity) => {
const contract = contractOf(entity);
const decided = new Set([...contract.columns, ...contract.withheld]);
// A failure names the new column: add it to `columns` to publish it to plugins,
// or to `withheld` if it is a credential.
expect(columnsOf(contract.table).filter((c) => !decided.has(c))).toEqual([]);
expect(columnsOf(tableOf(entity)).filter((c) => !decided.has(c))).toEqual([]);
},
);
});
/**
* `shaped` is `raw` put through the contract of `entity`: same keys in the same order
* minus the withheld ones, the same values, and every nested child row held to its own
* entity the same way.
*/
function expectOnlyWithheldDropped(entity: PluginEntityName, raw: unknown, shaped: unknown, where: string): void {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
expect(shaped, where).toBe(raw);
return;
}
const rawRow = raw as Record<string, unknown>;
const shapedRow = shaped as Record<string, unknown>;
const withheld: readonly string[] = contractOf(entity).withheld;
expect(Object.keys(shapedRow), where).toEqual(Object.keys(rawRow).filter((k) => !withheld.includes(k)));
const nested: Readonly<Record<string, PluginEntityName>> = PLUGIN_ENTITY_NESTED[entity] ?? {};
for (const key of Object.keys(shapedRow)) {
const child = nested[key];
const rawValue = rawRow[key];
if (!child) {
expect(shapedRow[key], `${where}.${key}`).toBe(rawValue);
} else if (Array.isArray(rawValue)) {
const shapedList = shapedRow[key] as unknown[];
expect(shapedList).toHaveLength(rawValue.length);
rawValue.forEach((item, i) => expectOnlyWithheldDropped(child, item, shapedList[i], `${where}.${key}[${i}]`));
} else {
expectOnlyWithheldDropped(child, rawValue, shapedRow[key], `${where}.${key}`);
}
}
}
describe('the output contract drops only the withheld fields', () => {
let host: PluginRpcHost;
let callHandler: (method: string, params: Record<string, unknown>) => Promise<unknown>;
let ownerId: number;
let memberId: number;
let tripId: number;
let dayId: number;
let dayIds: number[];
let placeId: number;
let journeyId: number;
beforeAll(async () => {
// Collab, journey, atlas, vacay and collections are addons; their methods refuse
// while the addon is off.
testDb.exec('UPDATE addons SET enabled = 1');
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
ownerId = owner.id;
@@ -77,12 +139,23 @@ describe('the output contract drops only the withheld fields', () => {
addTripMember(testDb, trip.id, member.id);
// A real credential in the row, so "withheld" is proven rather than vacuous.
testDb.prepare("UPDATE trips SET feed_token = 'feed-secret' WHERE id = ?").run(trip.id);
dayId = (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(trip.id) as { id: number }).id;
dayIds = (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as Array<{ id: number }>).map(
(d) => d.id,
);
placeId = createPlace(testDb, trip.id).id;
createDayAssignment(testDb, dayId, placeId);
const tag = createTag(testDb, owner.id);
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(placeId, tag.id);
createDayAssignment(testDb, dayIds[0], placeId);
createDayNote(testDb, dayIds[0], trip.id);
createBudgetItem(testDb, trip.id);
createPackingItem(testDb, trip.id);
createReservation(testDb, trip.id, { day_id: dayId });
createReservation(testDb, trip.id, { day_id: dayIds[0] });
createTodoItem(testDb, trip.id);
createCollabNote(testDb, trip.id, owner.id);
createDayAccommodation(testDb, trip.id, placeId, dayIds[0], dayIds[1]);
journeyId = createJourney(testDb, owner.id).id;
linkTripToJourney(testDb, journeyId, trip.id);
createJourneyEntry(testDb, journeyId, owner.id, { title: 'Arrival' });
const parts = await createPluginRpcHostParts(testDb);
host = parts.factory.create('contract', new Set<string>(KNOWN_PERMISSIONS), {
@@ -108,64 +181,129 @@ describe('the output contract drops only the withheld fields', () => {
};
});
/** The keys the contract may remove from `raw`: the entity's withheld columns. */
const expectOnlyWithheldDropped = (method: string, raw: unknown) => {
/** Calls `method` on its handler and holds its result to the method's contract. */
const callAndCheck = async (method: string, params: Record<string, unknown>): Promise<unknown> => {
const raw = await callHandler(method, params);
const output = PLUGIN_METHOD_OUTPUT[method as keyof typeof PLUGIN_METHOD_OUTPUT];
if (output.kind !== 'entity') throw new Error(`${method} is not an entity method`);
const withheld: readonly string[] = contractOf(output.entity).withheld;
const rawRows = (output.many ? raw : [raw]) as Array<Record<string, unknown>>;
const shapedRows = (output.many ? shapePluginOutput(method, raw) : [shapePluginOutput(method, raw)]) as Array<
Record<string, unknown>
>;
expect(rawRows.length).toBeGreaterThan(0);
rawRows.forEach((row, i) => {
// Same keys, same order, same values: only a withheld key may go.
expect(Object.keys(shapedRows[i])).toEqual(Object.keys(row).filter((k) => !withheld.includes(k)));
for (const key of Object.keys(shapedRows[i])) expect(shapedRows[i][key]).toBe(row[key]);
});
const shaped = shapePluginOutput(method, raw);
if (output.many) {
expect(Array.isArray(raw), method).toBe(true);
const rawRows = raw as unknown[];
expect(rawRows.length, method).toBeGreaterThan(0);
rawRows.forEach((item, i) => expectOnlyWithheldDropped(output.entity, item, (shaped as unknown[])[i], `${method}[${i}]`));
} else {
expect(raw, method).toBeTruthy();
expectOnlyWithheldDropped(output.entity, raw, shaped, method);
}
return raw;
};
const idOf = (value: unknown): number => (value as { id: number }).id;
it('OUTCONTRACT-001 the writes return every field they returned before, minus credentials', async () => {
const write = async (method: string, params: Record<string, unknown>) => {
const raw = await callHandler(method, params);
expectOnlyWithheldDropped(method, raw);
return raw as { id: number };
};
await write('trips.update', { tripId, input: { title: 'Renamed' } });
await write('trips.create', { input: { title: 'Another trip' } });
const place = await write('places.create', { tripId, input: { name: 'Museum', lat: 1, lng: 2 } });
await write('places.update', { tripId, placeId: place.id, input: { name: 'Museum of Art' } });
const day = await write('days.create', { tripId, input: { notes: 'spare day' } });
await write('days.update', { tripId, dayId: day.id, input: { title: 'Spare' } });
await write('days.create', { tripId, input: { dated: true } });
await write('itinerary.assign', { tripId, dayId, placeId: place.id });
const reservation = await write('reservations.create', { tripId, input: { title: 'Flight', type: 'flight' } });
await write('reservations.update', { tripId, reservationId: reservation.id, input: { title: 'Flight home' } });
const item = await write('packing.create', { tripId, input: { name: 'Socks' } });
await write('packing.update', { tripId, itemId: item.id, input: { name: 'Wool socks' } });
await callAndCheck('trips.update', { tripId, input: { title: 'Renamed' } });
await callAndCheck('trips.create', { input: { title: 'Another trip' } });
const place = idOf(await callAndCheck('places.create', { tripId, input: { name: 'Museum', lat: 1, lng: 2 } }));
await callAndCheck('places.update', { tripId, placeId: place, input: { name: 'Museum of Art' } });
await callAndCheck('places.update', { tripId, placeId, input: { name: 'Tagged place' } });
const day = idOf(await callAndCheck('days.create', { tripId, input: { notes: 'spare day' } }));
await callAndCheck('days.update', { tripId, dayId: day, input: { title: 'Spare' } });
await callAndCheck('days.update', { tripId, dayId: dayIds[0], input: { title: 'Arrival' } });
await callAndCheck('days.create', { tripId, input: { dated: true } });
await callAndCheck('itinerary.assign', { tripId, dayId: dayIds[1], placeId: place });
const reservation = idOf(
await callAndCheck('reservations.create', {
tripId,
input: {
title: 'Flight',
type: 'flight',
endpoints: [
{ role: 'from', name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.56 },
{ role: 'to', name: 'Rome', code: 'FCO', lat: 41.8, lng: 12.25 },
],
},
}),
);
await callAndCheck('reservations.update', { tripId, reservationId: reservation, input: { title: 'Flight home' } });
const accommodation = idOf(
await callAndCheck('accommodations.create', {
tripId,
input: { place_id: placeId, start_day_id: dayIds[1], end_day_id: dayIds[2] },
}),
);
await callAndCheck('accommodations.update', { tripId, accommodationId: accommodation, input: { notes: 'Late check-in' } });
const bag = idOf(await callAndCheck('packing.createBag', { tripId, input: { name: 'Backpack' } }));
await callAndCheck('packing.updateBag', { tripId, bagId: bag, input: { name: 'Big backpack' } });
const item = idOf(await callAndCheck('packing.create', { tripId, input: { name: 'Socks' } }));
await callAndCheck('packing.update', { tripId, itemId: item, input: { name: 'Wool socks' } });
const content = Buffer.from('boarding pass').toString('base64');
const file = await write('files.create', { tripId, input: { name: 'pass.txt', content_base64: content, mimetype: 'text/plain' } });
await write('files.update', { tripId, fileId: file.id, input: { description: 'Boarding pass' } });
const cost = await write('costs.create', { tripId, input: { name: 'Dinner', total_price: 10 } });
await write('costs.update', { tripId, itemId: cost.id, input: { name: 'Lunch' } });
const file = idOf(
await callAndCheck('files.create', { tripId, input: { name: 'pass.txt', content_base64: content, mimetype: 'text/plain' } }),
);
await callAndCheck('files.update', { tripId, fileId: file, input: { description: 'Boarding pass' } });
await callAndCheck('files.createLink', { tripId, fileId: file, opts: { reservation_id: reservation } });
const cost = idOf(await callAndCheck('costs.create', { tripId, input: { name: 'Dinner', total_price: 10 } }));
await callAndCheck('costs.update', { tripId, itemId: cost, input: { name: 'Lunch' } });
const note = idOf(await callAndCheck('daynotes.create', { tripId, dayId: dayIds[0], input: { text: 'Breakfast' } }));
await callAndCheck('daynotes.update', { tripId, dayId: dayIds[0], noteId: note, input: { text: 'Late breakfast' } });
const todo = idOf(await callAndCheck('todos.create', { tripId, input: { name: 'Book train' } }));
await callAndCheck('todos.update', { tripId, todoId: todo, input: { name: 'Book the train' } });
const tag = idOf(await callAndCheck('tags.create', { input: { name: 'Food' } }));
await callAndCheck('tags.update', { tagId: tag, input: { name: 'Good food' } });
await callAndCheck('collab.createNote', { tripId, input: { title: 'Ideas' } });
const poll = idOf(await callAndCheck('collab.createPoll', { tripId, input: { question: 'Where?', options: ['Rome', 'Milan'] } }));
await callAndCheck('collab.votePoll', { tripId, pollId: poll, optionIndex: 0 });
const message = idOf(await callAndCheck('collab.createMessage', { tripId, text: 'Hello' }));
await callAndCheck('collab.createMessage', { tripId, text: 'Reply', replyTo: message });
await callAndCheck('journal.createJourney', { input: { title: 'Italy' } });
const entry = idOf(await callAndCheck('journal.createEntry', { journeyId, input: { title: 'Day one', entry_date: '2026-05-01' } }));
await callAndCheck('journal.updateEntry', { entryId: entry, input: { story: 'We arrived.' } });
await callAndCheck('atlas.createBucketItem', { input: { name: 'Lisbon' } });
const collection = idOf(await callAndCheck('collections.create', { input: { name: 'Rome' } }));
await callAndCheck('collections.update', { id: collection, input: { name: 'Rome again' } });
});
it('OUTCONTRACT-002 the reads return every field they returned before, minus credentials', async () => {
const collection = idOf(await callHandler('collections.create', { input: { name: 'Read me' } }));
const reads: Array<[string, Record<string, unknown>]> = [
['trips.getById', { tripId }],
['trips.listMine', {}],
['trips.getPlaces', { tripId }],
['trips.getDays', { tripId }],
['trips.getReservations', { tripId }],
['trips.getAccommodations', { tripId }],
['reservations.listMine', {}],
['trips.members', { tripId }],
['users.getById', { id: memberId }],
['packing.list', { tripId }],
['packing.listBags', { tripId }],
['files.list', { tripId }],
['costs.getByTrip', { tripId }],
['costs.listMine', {}],
['daynotes.list', { tripId, dayId: dayIds[0] }],
['todos.list', { tripId }],
['tags.list', {}],
['categories.list', {}],
['collab.listNotes', { tripId }],
['collab.listPolls', { tripId }],
['collab.listMessages', { tripId }],
['journal.listMine', {}],
['journal.getEntries', { journeyId }],
['atlas.bucketList', {}],
['collections.listMine', {}],
['collections.get', { id: collection }],
['vacay.mine', {}],
];
for (const [method, params] of reads) expectOnlyWithheldDropped(method, await callHandler(method, params));
for (const [method, params] of reads) await callAndCheck(method, params);
});
it('OUTCONTRACT-004 the nested child rows are exercised, not vacuous', async () => {
const days = (await callHandler('trips.getDays', { tripId })) as Array<{ notes_items: unknown[]; assignments: unknown[] }>;
expect(days.some((d) => d.notes_items.length > 0)).toBe(true);
expect(days.some((d) => d.assignments.length > 0)).toBe(true);
const reservations = (await callHandler('trips.getReservations', { tripId })) as Array<{ endpoints: unknown[] }>;
expect(reservations.some((r) => r.endpoints.length > 0)).toBe(true);
});
it('OUTCONTRACT-003 through the router, a trip never carries its feed token', async () => {
@@ -11,6 +11,7 @@ import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { KNOWN_METHODS, UNCONDITIONAL_METHODS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import {
PLUGIN_ENTITY_CONTRACT,
PLUGIN_ENTITY_NESTED,
PLUGIN_METHOD_OUTPUT,
pluginEntityFields,
returnsEntity,
@@ -34,6 +35,28 @@ describe('the contract table', () => {
}
});
it.each(Object.entries(PLUGIN_ENTITY_NESTED))(
'OUTCONTRACT-UNIT-012 %s nests defined entities under its own derived keys',
(entity, nested) => {
const derived: readonly string[] = contractOf(entity as PluginEntityName).derived;
for (const [key, child] of Object.entries(nested ?? {})) {
expect(derived).toContain(key);
expect(ENTITIES).toContain(child);
}
},
);
it.each(ENTITIES.filter((e) => contractOf(e).table === null))(
'OUTCONTRACT-UNIT-013 the envelope %s has no columns of its own',
(entity) => {
const contract = contractOf(entity);
expect(contract.columns).toEqual([]);
expect(contract.withheld).toEqual([]);
expect(contract.wholeRow).toBe(false);
expect(contract.derived.length).toBeGreaterThan(0);
},
);
it.each(ENTITIES)('OUTCONTRACT-UNIT-003 %s lists each field once and never publishes a withheld column', (entity) => {
const contract = contractOf(entity);
const fields = pluginEntityFields(entity);
@@ -50,8 +73,9 @@ describe('the contract table', () => {
it('OUTCONTRACT-UNIT-005 returnsEntity tells the entity methods from the rest', () => {
expect(returnsEntity('trips.getById')).toBe(true);
expect(returnsEntity('costs.listMine')).toBe(true);
expect(returnsEntity('tags.list')).toBe(true);
expect(returnsEntity('db.query')).toBe(false);
expect(returnsEntity('tags.list')).toBe(false);
expect(returnsEntity('atlas.visited')).toBe(false);
expect(returnsEntity('no.such.method')).toBe(false);
});
});
@@ -71,17 +95,51 @@ describe('shapePluginOutput', () => {
expect(Object.keys(shaped as object)).toEqual(['title', 'id', 'day_count']);
});
it('OUTCONTRACT-UNIT-007 shapes every row of a list and leaves nested values untouched', () => {
const assignments = [{ id: 1, place: { id: 2, anything: true } }];
it('OUTCONTRACT-UNIT-007 shapes every row of a list and the child rows it carries', () => {
const place = { id: 2, anything: true };
const shaped = shapePluginOutput('trips.getDays', [
{ id: 1, date: '2026-05-01', assignments, secret_new: 1 },
{
id: 1,
date: '2026-05-01',
assignments: [{ id: 1, place, assignment_new_column: 1 }],
notes_items: [{ id: 5, text: 'Breakfast', day_note_new_column: 'x' }],
secret_new: 1,
},
{ id: 2, date: '2026-05-02', notes_items: [] },
]) as Array<Record<string, unknown>>;
expect(shaped).toEqual([
{ id: 1, date: '2026-05-01', assignments },
{ id: 1, date: '2026-05-01', assignments: [{ id: 1, place }], notes_items: [{ id: 5, text: 'Breakfast' }] },
{ id: 2, date: '2026-05-02', notes_items: [] },
]);
expect(shaped[0].assignments).toBe(assignments);
// A derived value without a nested entity (the assignment's place, built field by
// field) is passed on as the same object.
expect((shaped[0].assignments as Array<Record<string, unknown>>)[0].place).toBe(place);
});
it('OUTCONTRACT-UNIT-014 a child row is cut down however deep it sits', () => {
const shaped = shapePluginOutput('vacay.mine', {
plan: { id: 1, owner_id: 2, plan_new_column: 1, holiday_calendars: [{ id: 3, label: 'DE', calendar_new_column: 1 }] },
users: [{ id: 2, username: 'ana', color: '#fff' }],
isOwner: true,
envelope_new_key: 1,
});
expect(shaped).toEqual({
plan: { id: 1, owner_id: 2, holiday_calendars: [{ id: 3, label: 'DE' }] },
users: [{ id: 2, username: 'ana', color: '#fff' }],
isOwner: true,
});
});
it('OUTCONTRACT-UNIT-015 a single child row is shaped and a missing one keeps its form', () => {
expect(shapePluginOutput('collections.get', { collection: { id: 1, name: 'Rome', new_column: 1 }, places: [] })).toEqual({
collection: { id: 1, name: 'Rome' },
places: [],
});
expect(shapePluginOutput('collections.get', { collection: null, places: [] })).toEqual({ collection: null, places: [] });
expect(shapePluginOutput('trips.getReservations', [{ id: 1, endpoints: null }])).toEqual([{ id: 1, endpoints: null }]);
expect(shapePluginOutput('trips.getReservations', [{ id: 1, endpoints: [{ id: 2, code: 'FRA', secret: 1 }, 7] }])).toEqual([
{ id: 1, endpoints: [{ id: 2, code: 'FRA' }, 7] },
]);
});
it('OUTCONTRACT-UNIT-008 a missing row keeps its wire form', () => {
@@ -97,9 +155,9 @@ describe('shapePluginOutput', () => {
it('OUTCONTRACT-UNIT-009 a host or read-model result is returned as the same object', () => {
const own = [{ anything: 1 }];
expect(shapePluginOutput('db.query', own)).toBe(own);
const tags = [{ id: 1, name: 'work', brand_new_column: true }];
expect(shapePluginOutput('tags.list', tags)).toBe(tags);
expect(shapePluginOutput('no.such.method', tags)).toBe(tags);
const visited = { countries: [{ country_code: 'DE', brand_new_column: true }], regions: [] };
expect(shapePluginOutput('atlas.visited', visited)).toBe(visited);
expect(shapePluginOutput('no.such.method', visited)).toBe(visited);
});
});
@@ -111,9 +169,9 @@ class ProbeRpc {
return { id: 1, title: 'Japan', feed_token: 'secret', added_later: ctx.actingUserId };
}
@PluginMethod('tags.list', { permission: 'db:read:tags' })
tags() {
return [{ id: 1, name: 'work', added_later: true }];
@PluginMethod('atlas.visited', { permission: 'db:read:atlas' })
visited() {
return { countries: [{ country_code: 'DE', added_later: true }], regions: [] };
}
}
@@ -130,6 +188,9 @@ describe('the router applies the contract', () => {
});
it('OUTCONTRACT-UNIT-011 a read-model result reaches the plugin with every key', async () => {
expect(await dispatch('tags.list', 'db:read:tags')).toEqual([{ id: 1, name: 'work', added_later: true }]);
expect(await dispatch('atlas.visited', 'db:read:atlas')).toEqual({
countries: [{ country_code: 'DE', added_later: true }],
regions: [],
});
});
});
+24 -13
View File
@@ -247,23 +247,34 @@ parser would ignore one if you added it.
**What the reads return.** Every method's result is one of three kinds, and the SDK
exports the list per method as `PLUGIN_METHOD_RESULT`:
- **Entity rows** (`trip`, `place`, `day`, `reservation`, `packingItem`, `tripFile`,
`budgetItem`, `assignment`, `user`). A row carries exactly the fields TREK publishes
for that entity (`PLUGIN_ENTITY_FIELDS` in the SDK): the table's columns in
snake_case, then what TREK adds on top, such as a trip's `day_count`,
`place_count`, `is_owner`, `owner_username` and `shared_count`, a place's
`category`, `tags` and `ratings`, a reservation's `endpoints`, `travelers` and
`day_positions`, or a day's `assignments` and `notes_items`. A field appears only
where the method's read produces it (`trips.getById` has no `day_count`,
- **Entity rows**: the trip data (`trip`, `place`, `day`, `reservation`,
`accommodation`, `packingItem`, `packingBag`, `tripFile`, `fileLink`, `budgetItem`,
`assignment`, `dayNote`, `todo`, `user`), the collab board (`collabNote`,
`collabPoll`, `collabMessage`) and the acting user's own data (`tag`, `category`,
`journey`, `journalEntry`, `bucketItem`, `collection`). A row carries exactly the
fields TREK publishes for that entity (`PLUGIN_ENTITY_FIELDS` in the SDK): the
table's columns in snake_case, then what TREK adds on top, such as a trip's
`day_count`, `place_count`, `is_owner`, `owner_username` and `shared_count`, a
place's `category`, `tags` and `ratings`, a reservation's `endpoints`, `travelers`
and `day_positions`, or a day's `assignments` and `notes_items`. A field appears
only where the method's read produces it (`trips.getById` has no `day_count`,
`trips.listMine` has). Credentials are never part of a row: a trip's calendar
`feed_token` is withheld, and a user is only `id`, `username`, `display_name` and
`avatar`. A column TREK adds in a later release is **not** delivered until TREK
publishes it in that list, so the rows your plugin reads do not change shape on an
update. `Place.day_id` in the SDK types is deprecated and never set: a place's days
are the `assignments` of `getDays`.
- **Read models** (tags, to-dos, collab notes, polls and messages, journals and
entries, bags, collections, accommodations, Atlas and Vacay data): the same shape
TREK's own REST route returns, passed on unchanged. The SDK types them `unknown`.
update. That holds for the rows inside a row too: the fields listed in
`PLUGIN_ENTITY_NESTED` hold rows of another entity (a day's `notes_items` are
`dayNote` rows, a reservation's `endpoints` are `reservationEndpoint` rows) and
carry that entity's published fields only, however deep they sit; every other
added field is a value TREK builds field by field. `collections.listMine`,
`collections.get` and `vacay.mine` return envelopes around such rows
(`collectionListing`, `collectionDetail`, `vacayPlanData`). `Place.day_id` in the
SDK types is deprecated and never set: a place's days are the `assignments` of
`getDays`.
- **Read models**: small results TREK builds field by field from named columns, so
no stored row passes through them whole (a bag's members, the Atlas `visited`
codes, what a Vacay toggle did, a saved collection place or a copy summary, a
journal photo). The SDK types them `unknown`.
- **Host values**: `{ deleted }`, `{ sent }`, a model's answer, an access token, and
your own data (`ctx.db`, `ctx.meta`, another plugin's answer), returned as they are.
+1 -1
View File
@@ -11,7 +11,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
| Permission | Grants | Notes |
|---|---|---|
| `db:own` | Read/write the plugin's **own** SQLite file via `ctx.db` — `db.query`, `db.exec`, **and `db.migrate`** | A separate `plugin.db` per plugin under the plugin's data dir — never TREK's own `travel.db`. `db.migrate` runs a keyed, idempotent migration (schema/table creation, e.g. `CREATE TABLE`) once per id. `ATTACH`/`DETACH`/`VACUUM`/`PRAGMA`/`WITH RECURSIVE` are refused, and a `query` or `tx` stops at 100,000 rows or 2 s. |
| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`, `getDays`, `getAccommodations`, `listMine`, `members`) | Every call is **membership-checked** against the acting user — a plugin can't read a trip that user can't see. `getDays` returns each day with its `assignments` + `notes_items`; `getReservations` rows carry `endpoints` + `day_positions` like the REST list. `members` returns the roster (id + display fields only). Each row carries the fields TREK publishes for it (`PLUGIN_ENTITY_FIELDS` in the SDK), never the trip's calendar `feed_token`, and a column added in a later release is not delivered until it is published. |
| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`, `getDays`, `getAccommodations`, `listMine`, `members`) | Every call is **membership-checked** against the acting user: a plugin can't read a trip that user can't see. `getDays` returns each day with its `assignments` + `notes_items`; `getReservations` rows carry `endpoints` + `day_positions` like the REST list. `members` returns the roster (id + display fields only). Each row, and each row nested in it such as a day's notes or a reservation's endpoints, carries the fields TREK publishes for it (`PLUGIN_ENTITY_FIELDS` and `PLUGIN_ENTITY_NESTED` in the SDK), never the trip's calendar `feed_token`, and a column added in a later release is not delivered until it is published. |
| `db:read:users` | Read-only public profile via `ctx.users.getById` | Returns id, username, display name, avatar only — **never** password hashes, tokens, or secrets. |
| `db:read:packing` | Read-only packing items of a trip via `ctx.packing.list(tripId)` | Membership-checked, and scoped to the acting user's visibility — a plugin never sees another member's private packing items. |
| `db:read:files` | Read-only files of a trip via `ctx.files.list(tripId)` | Membership-checked; trashed files excluded. |