Skip to content

Commit cd22efd

Browse files
Update SDK pages
1 parent 155c71f commit cd22efd

3 files changed

Lines changed: 94 additions & 28 deletions

File tree

docs/sdk/cli-reference.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@ Commands that only perform side effects and do not define a structured result ma
3535
even when `--json` is passed.
3636

3737
Errors, warnings, progress, and diagnostic messages are written to stderr. After argument parsing,
38-
a command failure under `--json` emits a JSON error envelope to stderr. CLI errors include a stable
39-
`error.code` and may include structured `error.next` and `error.context` fields for automated
40-
recovery. Diagnostic lines may precede the error envelope, and stdout is not guaranteed to contain
41-
an error object.
38+
a command failure under `--json` emits a JSON error envelope to stderr. Failures you can act on — an
39+
invalid or missing option, a resource that does not exist, an invalid configuration, or an unmet
40+
precondition — carry a stable `error.code` such as `PROFILE_NOT_FOUND`, `TAILORDB_NAMESPACE_NOT_FOUND`,
41+
or `MIGRATION_SCRIPT_REQUIRED`. Where a remediation exists, the envelope also includes
42+
`error.suggestion`, `error.help` (the `--help` invocation for the failing command), `error.next` (a
43+
runnable command), or `error.context`. `UNEXPECTED_ERROR` marks failures without a dedicated code,
44+
including SDK-internal errors. Diagnostic lines may precede the error envelope, and stdout is not
45+
guaranteed to contain an error object.
4246

4347
Authentication failures distinguish missing credentials (`AUTH_TOKEN_NOT_FOUND`), a missing saved
4448
user (`AUTH_USER_NOT_FOUND`), an expired token (`AUTH_TOKEN_EXPIRED`), and a failed token refresh
@@ -67,6 +71,33 @@ Capture the original failure's stderr and exit code before retrying. Argument pa
6771
before the CLI starts may produce plain text even with `--json`. A failed deployment may have
6872
already applied changes, so inspect its output before deciding to run it again.
6973

74+
### GitHub Actions Annotations
75+
76+
When `GITHUB_ACTIONS` is exactly `true`, a command that ends in failure also writes one
77+
`::error::` workflow command to stderr, so the failure appears as an annotation on the run
78+
instead of only inside the scrolled log. The annotation repeats what the CLI already prints:
79+
its `title` is the error code (`AUTH_TOKEN_NOT_FOUND`, `PLUGIN_GENERATION_FAILED`, ...), and its
80+
body carries the same details, suggestion, and next action. Colors are stripped and newlines are
81+
encoded, so the annotation is a single line.
82+
83+
Exactly one annotation is written per failed command, and only for the failure that ends it.
84+
Warnings and individually reported problems stay plain stderr output. The bundled CLI plugins
85+
(`seed`, `setup`, `tailordb-erd`) annotate their failures the same way. A command that exits
86+
without reporting through the CLI's error path, such as one relaying a failed remote execution,
87+
writes no annotation.
88+
89+
Set `TAILOR_GITHUB_ACTIONS_ANNOTATIONS=false` (also `off`, `no`, or `0`) to turn annotations off.
90+
Passing `--json` also suppresses them, so a workflow step that parses `--json` output gets only the
91+
error envelope on stderr. The flag is honored even when the command fails during argument parsing,
92+
before the envelope itself becomes available.
93+
94+
An annotation does not by itself fail a step: the step still fails on the CLI's exit code, which
95+
is unchanged. Workflows that already echo their own `::error::` around the CLI keep working;
96+
those messages describe the workflow's own checks, which can fail even when the CLI succeeds.
97+
98+
Annotations do not yet carry `file=`/`line=` source locations, and `generate` and `deploy` do not
99+
group their per-service progress.
100+
70101
## Common Options
71102

72103
The following options are available for most commands:
@@ -116,6 +147,7 @@ You can use environment variables to configure workspace and authentication:
116147
| `TAILOR_BUNDLE_CONCURRENCY` | Max concurrent bundle workers for `deploy` (resolvers/executors/workflows). Defaults to CPU count |
117148
| `TAILOR_APPLY_CONCURRENCY` | Max concurrent platform RPCs during `apply`/`deploy`. Defaults to 16 |
118149
| `VISUAL` / `EDITOR` | Preferred editor for commands that open files (e.g., `vim`, `code`, `nano`) |
150+
| `TAILOR_GITHUB_ACTIONS_ANNOTATIONS` | GitHub Actions failure annotations: `on` (default) or `off` |
119151
| `TAILOR_CRASH_REPORTS_LOCAL` | Local crash log writing: `on` (default) or `off` |
120152
| `TAILOR_CRASH_REPORTS_REMOTE` | Automatic crash report submission: `off` (default) or `on` |
121153

docs/sdk/plugin/custom.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,37 @@ const AuditLog = await getGeneratedTable(configPath, "@example/audit-log", null,
324324
5. Caches the result to avoid redundant processing
325325
6. Returns the generated table matching the specified kind
326326

327+
## getExtendedTable Helper
328+
329+
A table that plugins are attached to gains the fields those plugins return in `extends.fields`, but only in the table `tailor generate` registers — the object exported from the table's source file stays as written. `getExtendedTable()` returns the table with every plugin-added field applied, so tooling that reads the table at runtime sees the same fields `tailor generate` does.
330+
331+
```typescript
332+
import { join } from "node:path";
333+
import { getExtendedTable } from "@tailor-platform/sdk/plugin";
334+
import { customer } from "./tailordb/customer";
335+
336+
const configPath = join(import.meta.dirname, "./tailor.config.ts");
337+
338+
const extendedCustomer = await getExtendedTable(configPath, customer);
339+
extendedCustomer.fields.deletedAt; // added by a plugin attached with .plugin()
340+
```
341+
342+
**Parameters:**
343+
344+
- `configPath`: Path to `tailor.config.ts` (absolute or relative to cwd)
345+
- `sourceTable`: The TailorDB table as exported from its source file
346+
347+
**How it works:**
348+
349+
1. Returns `sourceTable` itself when no plugin is attached to it
350+
2. Loads and caches the config from the given path
351+
3. Auto-resolves the namespace from config
352+
4. Calls each attached plugin's `onTableLoaded()` in the order of the `.plugin()` calls, each seeing the fields the plugins before it added
353+
5. Caches the result per config path and table
354+
6. Returns a new table with the added fields; `sourceTable` is not changed
355+
356+
The seed schema files `tailor generate` writes for tables with plugins attached use this helper, so `tailor seed validate` checks plugin-added fields like the table's own.
357+
327358
## Examples
328359

329360
### Definition-time Plugin (Soft Delete)
@@ -646,10 +677,11 @@ of whether `.files()` or `.plugin()` was called first. `tailor generate` also re
646677
collision at runtime, as a backstop for any case a table's static type doesn't otherwise catch.
647678

648679
This only affects the table's static type. The corresponding field exists on the table's
649-
generated schema, and on the table object's own `fields`, only after `tailor generate` actually
650-
applies `extends.fields`. Before that, reading an injected field directly off the table
651-
(`table.fields.status`) returns `undefined`, and `pickFields(["status"])` throws — call these only
652-
with the table's originally declared fields, not ones a plugin injects.
680+
generated schema, and on the table `tailor generate` registers, only once `extends.fields` is
681+
applied; the table object exported from the source file never gains it. Reading an injected field
682+
directly off that object (`table.fields.status`) returns `undefined`, and `pickFields(["status"])`
683+
throws — call these only with the table's originally declared fields, or load the table with
684+
[`getExtendedTable()`](#getextendedtable-helper) first.
653685

654686
To keep the declared type and the runtime implementation in sync, give `Plugin`'s optional third
655687
type parameter the same shape and use it inside `onTableLoaded`:

docs/sdk/testing.md

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -149,26 +149,30 @@ Pass `{ onUnhandled: "error" }` to make an unmatched query fail instead of retur
149149

150150
Instead of staging responses, back TailorDB with `@electric-sql/pglite` — an in-memory PostgreSQL (install it as a devDependency) — so the queries a resolver, executor, or workflow job issues through `getDB()` execute against real data. `getDB(namespace)` needs no test-side swap: acquire the mock, and each namespace you list resolves to its PGlite instance.
151151

152-
Create the tables the test touches with `CREATE TABLE` statements matching the generated Kysely types — `text` for string and enum fields, `timestamptz` for date/datetime, `jsonb` for nested objects. The schema only has to match what your code reads and writes, not TailorDB's storage; relations are not enforced.
152+
Let `kyselyTypePlugin` generate the `CREATE TABLE` script for you: set `pgliteSchemaPath` next to `distPath`, and `tailor generate` writes a module exporting one script per namespace, derived from the same table definitions as the Kysely types.
153+
154+
```typescript
155+
// tailor.config.ts
156+
kyselyTypePlugin({
157+
distPath: "./generated/db.ts",
158+
pgliteSchemaPath: "./generated/db.pglite.ts",
159+
});
160+
```
161+
162+
Run the namespace's script once per PGlite instance. Every statement is `IF NOT EXISTS`, so applying it again to an instance that already has the tables is harmless.
153163

154164
```typescript
155165
import { PGlite } from "@electric-sql/pglite";
156166
import { mockTailordbWithPGlite } from "@tailor-platform/sdk/vitest";
157167
import { afterAll, beforeAll, expect, test } from "vitest";
158168
import { getDB } from "../generated/db";
169+
import { pgliteSchema } from "../generated/db.pglite";
159170
import resolver from "./upsertUsers";
160171

161172
const pglite = new PGlite();
162173

163174
beforeAll(async () => {
164-
await pglite.exec(`
165-
CREATE TABLE "User" (
166-
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
167-
"name" text NOT NULL,
168-
"email" text NOT NULL,
169-
"age" integer NOT NULL
170-
);
171-
`);
175+
await pglite.exec(pgliteSchema["main-db"]);
172176
});
173177

174178
afterAll(async () => {
@@ -201,21 +205,19 @@ test("upserts against real rows", async () => {
201205
});
202206
```
203207

204-
A `.serial()` field is omitted from generated `getDB()` inserts, so its PGlite column must generate a value. Use an identity for an integer serial. For a formatted string serial, create a sequence and reproduce the format in its `DEFAULT` expression:
208+
The generated columns follow the Kysely types, not TailorDB's storage: `text` for string and enum fields, `timestamptz` for datetime, `date` and `time` for date and time, `numeric` for decimal, rounded to the configured scale and read back with exactly that many fractional digits, `jsonb` for nested objects (and arrays of them), Postgres arrays for other array fields. `id` is a generated `uuid` primary key, `.unique()` fields and unique `.indexes()` are enforced, so `ON CONFLICT` upserts behave, and `.default()` values become column defaults (`"now"` becomes the current time). `.serial()` fields are assigned by the database from the configured `start`, `maxValue`, and format. Relations are not enforced.
205209

206-
```sql
207-
CREATE SEQUENCE "invoiceNumberSequence" START WITH 1000;
208-
CREATE TABLE "Invoice" (
209-
"sequentialId" integer GENERATED BY DEFAULT AS IDENTITY (START WITH 1),
210-
"invoiceNumber" text NOT NULL
211-
DEFAULT ('INV-' || lpad(nextval('"invoiceNumberSequence"')::text, 5, '0'))
212-
);
213-
```
210+
What the script cannot reproduce:
211+
212+
- Hooks, validations, and permissions do not run. A required field whose value only its own field-level create hook supplies is created nullable, so inserts that omit it succeed; give it a `.default()` if the test reads it back. A field filled by a table-level hook stays `NOT NULL`, as its Kysely type still requires it on insert.
213+
- Serial formats are reproduced for a single `%d`, `%x`, or `%X` specifier with an optional zero-padded width; an octal `%o` format fails generation with an error naming the field.
214+
- A datetime inside a nested object reads back as a string from `jsonb`, not a `Date`.
215+
- On a persistent PGlite (`dataDir`), tables created by an earlier run are kept as they were; drop them or start from an empty directory after changing a table definition.
214216

215-
PGlite does not apply the TailorDB `.serial()` configuration itself. Match the `start`, `format`, and any limit that the behavior under test relies on.
217+
To hand-write DDL instead — for a table not in the schema, or to add a constraint — run your own statements after the script, or without it.
216218

217219
- The PGlite instance is yours: the mock never closes it, so close it in `afterAll`. Reuse one instance across a suite — creating one per test is slow.
218-
- Pass the same instance under several namespaces to drive them against one shared database.
220+
- Pass the same instance under several namespaces to drive them against one shared database. Two namespaces with a same-named table cannot share one instance, because the second script leaves the first table as it is.
219221
- Seed through `getDB` itself. When a column type rejects a value that only the test must stage, use `createKyselyPGlite<Unmigrated<...>>(pglite)` instead — see [Testing Migrations Locally](services/tailordb-migration.md#testing-migrations-locally). This only affects test setup; it cannot supply a `.serial()` value for an insert issued by the code under test.
220222
- Transactions on a shared instance are serialized: while one is open, queries from other `getDB` instances wait. Do not use `test.concurrent` with a shared instance, and do not query the same instance through a second `getDB` from inside a transaction — that waits on itself.
221223
- PGlite runs full PostgreSQL while TailorDB supports a subset of it, and TailorDB hooks, validations, and permissions do not run here — a test passing on PGlite can still behave differently on the platform. Keep [`mockTailordb`](#tailordb-mock) or [`createKyselyMock`](#kysely-layer-mock-createkyselymock) tests for query shape and error paths, and E2E tests for platform behavior.

0 commit comments

Comments
 (0)