@tailor-platform/sdk 2.8.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/application-B4tCGHiQ.mjs +1 -0
- package/dist/{application-D2E3FDfF.mjs → application-CcrP-v7z.mjs} +32 -31
- package/dist/application-CcrP-v7z.mjs.map +1 -0
- package/dist/cli/lib.mjs +1 -1
- package/dist/cli/main.mjs +18 -18
- package/dist/cli/main.mjs.map +1 -1
- package/dist/cli/shared/seed-context.d.mts +3 -1
- package/dist/completion/zsh-worker.zsh +1 -1
- package/dist/configure/config/index.d.mts +2 -1
- package/dist/configure/index.d.mts +14 -14
- package/dist/configure/index.mjs +1 -1
- package/dist/configure/index.mjs.map +1 -1
- package/dist/configure/services/aigateway/index.d.mts +1 -3
- package/dist/configure/services/idp/index.d.mts +1 -1
- package/dist/configure/services/resolver/resolver.d.mts +1 -1
- package/dist/configure/services/tailordb/schema.d.mts +22 -13
- package/dist/configure/types/index.d.mts +1 -1
- package/dist/idp-G_ojPBB5.mjs.map +1 -1
- package/dist/plugin/builtin/seed/index.mjs +1 -1
- package/dist/plugin/types.d.mts +17 -7
- package/dist/{register-ts-hook-CQuJ7h5G.mjs → register-ts-hook-YqgUg8P7.mjs} +63 -63
- package/dist/register-ts-hook-YqgUg8P7.mjs.map +1 -0
- package/dist/{schema-AYG4OhXY.mjs → schema-6d_OHyZf.mjs} +2 -2
- package/dist/schema-6d_OHyZf.mjs.map +1 -0
- package/dist/{seed-DwqRFdqP.mjs → seed-CMkupmX8.mjs} +41 -17
- package/dist/seed-CMkupmX8.mjs.map +1 -0
- package/dist/service-BzsSm2f6.mjs +7 -0
- package/dist/service-BzsSm2f6.mjs.map +1 -0
- package/dist/service-CmwD9bwv.mjs +1 -0
- package/dist/types/helpers.d.mts +3 -1
- package/dist/vitest/index.d.mts +3 -2
- package/dist/vitest/index.mjs +1 -1
- package/dist/vitest/index.mjs.map +1 -1
- package/dist/vitest/mocks/file.d.mts +1 -1
- package/dist/vitest/mocks/tailordb-pglite.d.mts +88 -0
- package/dist/vitest/pglite-kysely.d.mts +41 -11
- package/docs/plugin/custom.md +95 -0
- package/docs/plugin/index.md +5 -0
- package/docs/services/tailordb-migration.md +14 -8
- package/docs/testing.md +78 -2
- package/package.json +9 -9
- package/dist/application-D2E3FDfF.mjs.map +0 -1
- package/dist/application-rySTKrFa.mjs +0 -1
- package/dist/register-ts-hook-CQuJ7h5G.mjs.map +0 -1
- package/dist/schema-AYG4OhXY.mjs.map +0 -1
- package/dist/seed-DwqRFdqP.mjs.map +0 -1
- package/dist/service-COC9whur.mjs +0 -1
- package/dist/service-DDon86tM.mjs +0 -7
- package/dist/service-DDon86tM.mjs.map +0 -1
|
@@ -46,10 +46,10 @@ declare function mockFile(options?: MockFileOptions): {
|
|
|
46
46
|
calls: FileCall[];
|
|
47
47
|
clear(): void;
|
|
48
48
|
reset(): void;
|
|
49
|
+
delete: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string) => Promise<void>>;
|
|
49
50
|
upload: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string, data: string | ArrayBuffer | Uint8Array | number[], options?: FileUploadOptions) => Promise<FileUploadResponse>>;
|
|
50
51
|
download: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string) => Promise<FileDownloadResponse>>;
|
|
51
52
|
downloadAsBase64: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string) => Promise<FileDownloadAsBase64Response>>;
|
|
52
|
-
delete: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string) => Promise<void>>;
|
|
53
53
|
getMetadata: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string) => Promise<FileMetadata>>;
|
|
54
54
|
downloadStream: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string) => Promise<FileDownloadStreamResponse>>;
|
|
55
55
|
uploadStream: Mock<(namespace: string, tableName: string, fieldName: string, recordId: string, readableStream: ReadableStream<Uint8Array | ArrayBuffer>, options?: FileUploadStreamOptions) => Promise<FileUploadResponse>>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PGliteClient } from "../pglite-kysely.mjs";
|
|
2
|
+
//#region src/vitest/mocks/tailordb-pglite.d.ts
|
|
3
|
+
/** A query executed through the PGlite-backed TailorDB client. */
|
|
4
|
+
interface ExecutedPGliteQuery {
|
|
5
|
+
/** Namespace of the `getDB` call that issued the query. */
|
|
6
|
+
namespace: string;
|
|
7
|
+
/** SQL text with positional (`$1`, `$2`, ...) placeholders. */
|
|
8
|
+
query: string;
|
|
9
|
+
/** Parameter values bound to the placeholders. */
|
|
10
|
+
params: unknown[];
|
|
11
|
+
}
|
|
12
|
+
/** Options for {@link mockTailordbWithPGlite}. */
|
|
13
|
+
interface MockTailordbPGliteOptions {
|
|
14
|
+
/**
|
|
15
|
+
* PGlite instance per `getDB` namespace. A `getDB` call for a namespace
|
|
16
|
+
* missing here throws instead of falling back to another instance. Pass the
|
|
17
|
+
* same instance under several namespaces to share one database between them.
|
|
18
|
+
*/
|
|
19
|
+
namespaces: Record<string, PGliteClient>;
|
|
20
|
+
}
|
|
21
|
+
interface CreatedClient {
|
|
22
|
+
namespace: string;
|
|
23
|
+
ended: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Acquire a disposable mock that backs the generated `getDB(namespace)` with
|
|
27
|
+
* PGlite, so resolver/executor/workflow code runs unchanged and its queries
|
|
28
|
+
* execute as real SQL on an in-memory Postgres. Restored on dispose; the
|
|
29
|
+
* PGlite instances are borrowed, never closed — close them yourself (e.g. in
|
|
30
|
+
* `afterAll`).
|
|
31
|
+
*
|
|
32
|
+
* Create the tables a test needs up front with `CREATE TABLE` statements
|
|
33
|
+
* matching the generated Kysely types. PGlite runs full PostgreSQL while
|
|
34
|
+
* TailorDB supports a subset of it, so a statement passing here can still be
|
|
35
|
+
* rejected by the platform.
|
|
36
|
+
*
|
|
37
|
+
* Transactions on a shared instance are serialized: while one is open,
|
|
38
|
+
* queries from other `getDB` instances on the same PGlite instance wait for
|
|
39
|
+
* it to finish. Do not run such tests with `test.concurrent`, and do not
|
|
40
|
+
* query the same instance through a second `getDB` from inside a transaction
|
|
41
|
+
* — that waits on itself.
|
|
42
|
+
* @param options - PGlite instance registration per namespace
|
|
43
|
+
* @returns Disposable TailorDB mock control object
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* import { PGlite } from "@electric-sql/pglite";
|
|
47
|
+
* import { mockTailordbWithPGlite } from "@tailor-platform/sdk/vitest";
|
|
48
|
+
* import { getDB } from "../generated/tailordb";
|
|
49
|
+
*
|
|
50
|
+
* const pglite = new PGlite();
|
|
51
|
+
* afterAll(() => pglite.close());
|
|
52
|
+
*
|
|
53
|
+
* test("real SQL", async () => {
|
|
54
|
+
* using _db = mockTailordbWithPGlite({ namespaces: { tailordb: pglite } });
|
|
55
|
+
* await pglite.query(`CREATE TABLE "User" ("id" uuid PRIMARY KEY, "name" text NOT NULL)`);
|
|
56
|
+
* await getDB("tailordb").insertInto("User").values({ id: crypto.randomUUID(), name: "a" }).execute();
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
declare function mockTailordbWithPGlite(options: MockTailordbPGliteOptions): {
|
|
61
|
+
/** The mock `tailordb.Client` constructor (`vi.fn`). */
|
|
62
|
+
Client: import("vitest").Mock<(this: any, config?: {
|
|
63
|
+
namespace?: string;
|
|
64
|
+
}) => void>;
|
|
65
|
+
/**
|
|
66
|
+
* All queries executed on the PGlite instances, in order, with the
|
|
67
|
+
* namespace that issued each.
|
|
68
|
+
* @returns Executed queries array
|
|
69
|
+
*/
|
|
70
|
+
readonly executedQueries: ExecutedPGliteQuery[];
|
|
71
|
+
/**
|
|
72
|
+
* All TailorDB clients created, with their namespace and end state.
|
|
73
|
+
* @returns Created clients array
|
|
74
|
+
*/
|
|
75
|
+
readonly createdClients: CreatedClient[];
|
|
76
|
+
/**
|
|
77
|
+
* Clear recorded queries and clients while keeping the mock installed.
|
|
78
|
+
* Throws if a transaction is open.
|
|
79
|
+
*/
|
|
80
|
+
clear(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Reset recorded state and restore the default client behavior.
|
|
83
|
+
* Throws if a transaction is open.
|
|
84
|
+
*/
|
|
85
|
+
reset(): void;
|
|
86
|
+
} & Disposable;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { ExecutedPGliteQuery, MockTailordbPGliteOptions, mockTailordbWithPGlite };
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import { Kysely } from "kysely";
|
|
1
|
+
import { ColumnType, Kysely } from "kysely";
|
|
2
2
|
//#region src/vitest/pglite-kysely.d.ts
|
|
3
|
+
/** Result of a {@link PGliteClient.query} call. */
|
|
4
|
+
interface PGliteQueryResult {
|
|
5
|
+
/** Rows returned by the statement. */
|
|
6
|
+
rows: unknown[];
|
|
7
|
+
/** Number of rows an INSERT/UPDATE/DELETE touched. */
|
|
8
|
+
affectedRows?: number;
|
|
9
|
+
/** Postgres command tag of the statement (`"SELECT"`, `"INSERT"`, ...). */
|
|
10
|
+
command?: string;
|
|
11
|
+
/** Row count reported alongside the command tag. */
|
|
12
|
+
rowCount?: number;
|
|
13
|
+
}
|
|
3
14
|
/**
|
|
4
15
|
* The subset of a `@electric-sql/pglite` `PGlite` instance used by
|
|
5
16
|
* {@link createKyselyPGlite}. Any client with a compatible `query`/`close`
|
|
@@ -7,18 +18,37 @@ import { Kysely } from "kysely";
|
|
|
7
18
|
*/
|
|
8
19
|
interface PGliteClient {
|
|
9
20
|
/** Run a single SQL statement with positional (`$1`, `$2`, ...) parameters. */
|
|
10
|
-
query(query: string, params?: unknown[]): Promise<
|
|
11
|
-
rows: unknown[];
|
|
12
|
-
affectedRows?: number;
|
|
13
|
-
}>;
|
|
21
|
+
query(query: string, params?: unknown[]): Promise<PGliteQueryResult>;
|
|
14
22
|
/** Release the underlying database. Called by `db.destroy()`. */
|
|
15
23
|
close(): Promise<void>;
|
|
16
24
|
}
|
|
25
|
+
type WritableAs<S, W> = [S] extends [W] ? W : [W] extends [S] ? S : W | Exclude<S, W>;
|
|
26
|
+
type UnmigratedColumn<C> = C extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, WritableAs<S, I>, WritableAs<S, U>> : C;
|
|
27
|
+
/**
|
|
28
|
+
* `DB` as its rows stand before the migration script has run: every column
|
|
29
|
+
* accepts on insert and update whatever it can still hold on read.
|
|
30
|
+
*
|
|
31
|
+
* The generated `db.ts` types a column the migration makes required as
|
|
32
|
+
* `ColumnType<T | null, T, T>`, and an enum whose values it narrows as
|
|
33
|
+
* `ColumnType<Before, After, After>`, so `migrate.ts` cannot write a null
|
|
34
|
+
* or a removed value into them — and neither can a test that has to stage
|
|
35
|
+
* the rows the script converts. Type the PGlite instance with
|
|
36
|
+
* `Unmigrated<Database>` to stage them; `main` still receives a
|
|
37
|
+
* `Transaction<Database>`.
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const db = createKyselyPGlite<Unmigrated<Database>>(new PGlite());
|
|
41
|
+
* await db.insertInto("User").values({ name: "a", email: null }).execute();
|
|
42
|
+
* await db.transaction().execute((trx) => main(trx));
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
type Unmigrated<DB> = { [T in keyof DB]: { [C in keyof DB[T]]: UnmigratedColumn<DB[T][C]>; }; };
|
|
17
46
|
/**
|
|
18
47
|
* Create a Kysely instance backed by a PGlite in-memory Postgres, for
|
|
19
48
|
* executing a migration script's queries against real data in tests.
|
|
20
|
-
* Pass the migration's schema as the type argument
|
|
21
|
-
*
|
|
49
|
+
* Pass the migration's schema as the type argument — wrapped in
|
|
50
|
+
* {@link Unmigrated} so the test can stage the rows the script has not yet
|
|
51
|
+
* backfilled: `createKyselyPGlite<Unmigrated<Database>>(new PGlite())`.
|
|
22
52
|
*
|
|
23
53
|
* PGlite runs full PostgreSQL while TailorDB supports a subset of it, so a
|
|
24
54
|
* statement passing here can still be rejected by the platform; keep a
|
|
@@ -27,17 +57,17 @@ interface PGliteClient {
|
|
|
27
57
|
* @returns A Kysely instance that executes queries on the client and closes it on `destroy()`
|
|
28
58
|
* @example
|
|
29
59
|
* ```typescript
|
|
30
|
-
* // migrations/0005/migrate.test.ts
|
|
60
|
+
* // migrations/0005/migrate.pglite.test.ts
|
|
31
61
|
* import { PGlite } from "@electric-sql/pglite";
|
|
32
|
-
* import { createKyselyPGlite } from "@tailor-platform/sdk/vitest";
|
|
62
|
+
* import { createKyselyPGlite, type Unmigrated } from "@tailor-platform/sdk/vitest";
|
|
33
63
|
* import type { Database } from "./db";
|
|
34
64
|
* import { main } from "./migrate";
|
|
35
65
|
*
|
|
36
|
-
* const db = createKyselyPGlite<Database
|
|
66
|
+
* const db = createKyselyPGlite<Unmigrated<Database>>(new PGlite());
|
|
37
67
|
* // create tables matching db.ts, insert rows, then:
|
|
38
68
|
* await db.transaction().execute((trx) => main(trx));
|
|
39
69
|
* ```
|
|
40
70
|
*/
|
|
41
71
|
declare function createKyselyPGlite<DB = Record<string, never>>(client: PGliteClient): Kysely<DB>;
|
|
42
72
|
//#endregion
|
|
43
|
-
export { PGliteClient, createKyselyPGlite };
|
|
73
|
+
export { PGliteClient, PGliteQueryResult, Unmigrated, createKyselyPGlite };
|
package/docs/plugin/custom.md
CHANGED
|
@@ -83,6 +83,13 @@ interface Plugin<TableConfig = unknown, PluginConfig = unknown> {
|
|
|
83
83
|
| `executors` | `PluginGeneratedExecutor[]` | Additional executors to generate |
|
|
84
84
|
| `extends` | `{ fields?: Record<string, TailorAnyDBField> }` | Fields to add to the source table |
|
|
85
85
|
|
|
86
|
+
Tables in `tables` are validated as complete TailorDB table definitions before registration.
|
|
87
|
+
Fields in `extends.fields` are validated as part of the resulting source table. You can return a
|
|
88
|
+
table builder directly or a structural copy such as `{ ...db.table(...) }`; a copy is accepted as
|
|
89
|
+
long as it retains valid table schema properties. Malformed output stops the build with an error
|
|
90
|
+
that identifies the plugin and relevant table output, without partially registering tables from
|
|
91
|
+
that source table's plugin processing.
|
|
92
|
+
|
|
86
93
|
**Use cases**:
|
|
87
94
|
|
|
88
95
|
- Generate derived tables (e.g., archive tables, history tables) from user-defined tables
|
|
@@ -115,6 +122,10 @@ onTableLoaded(context) {
|
|
|
115
122
|
|
|
116
123
|
Same as `TablePluginOutput` but without `extends` (namespace plugins cannot extend a source table).
|
|
117
124
|
|
|
125
|
+
Tables in `tables` undergo the same validation and support structural copies. If any returned
|
|
126
|
+
table is malformed, the build stops before namespace-generated tables are registered and the
|
|
127
|
+
error identifies the plugin and relevant table output.
|
|
128
|
+
|
|
118
129
|
**Use cases**:
|
|
119
130
|
|
|
120
131
|
- Generate tables that don't derive from a specific user table (e.g., audit log, settings table)
|
|
@@ -575,6 +586,90 @@ declare module "@tailor-platform/sdk" {
|
|
|
575
586
|
}
|
|
576
587
|
```
|
|
577
588
|
|
|
589
|
+
### Injecting fields into the attached table's type (declaration merging)
|
|
590
|
+
|
|
591
|
+
A plugin that adds fields to a table via `onTableLoaded`'s `extends.fields` (see
|
|
592
|
+
[onTableLoaded](#ontableloaded) above) can also make those fields show up on the table's own
|
|
593
|
+
static type right away, computed from the literal per-table config passed to `.plugin()`. Provide
|
|
594
|
+
a declaration merge for the `PluginFieldExtensions` interface, keyed by the same `id` used in
|
|
595
|
+
`PluginConfigs`:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
// your-plugin/types.d.ts (shipped with your plugin package)
|
|
599
|
+
import type { TailorDBField } from "@tailor-platform/sdk";
|
|
600
|
+
|
|
601
|
+
declare module "@tailor-platform/sdk" {
|
|
602
|
+
interface PluginConfigs<Fields extends string> {
|
|
603
|
+
"@example/lifecycle": {
|
|
604
|
+
transitions: Record<string, { from: readonly string[]; to: string }>;
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
interface PluginFieldExtensions<Fields extends string, Config> {
|
|
609
|
+
"@example/lifecycle": Config extends {
|
|
610
|
+
transitions: infer T extends Record<string, { from: readonly string[]; to: string }>;
|
|
611
|
+
}
|
|
612
|
+
? {
|
|
613
|
+
status: TailorDBField<
|
|
614
|
+
{ type: "enum"; array: false },
|
|
615
|
+
T[keyof T]["from"][number] | T[keyof T]["to"]
|
|
616
|
+
>;
|
|
617
|
+
}
|
|
618
|
+
: never;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
With this in place, the table returned by `.plugin()` already has the derived field. The status enum
|
|
624
|
+
includes every state that appears anywhere in `transitions` — both a `from` state that a transition
|
|
625
|
+
never produces (like the initial `"PENDING"` below) and every `to` state:
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
import { db } from "@tailor-platform/sdk";
|
|
629
|
+
|
|
630
|
+
const approvalRequest = db.table("ApprovalRequest", { title: db.string() }).plugin({
|
|
631
|
+
"@example/lifecycle": {
|
|
632
|
+
transitions: {
|
|
633
|
+
approve: { from: ["PENDING"], to: "APPROVED" },
|
|
634
|
+
reject: { from: ["PENDING"], to: "REJECTED" },
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
// approvalRequest's type now includes status: "PENDING" | "APPROVED" | "REJECTED"
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
The type registered in `PluginFieldExtensions` must describe only the fields being added — it is
|
|
642
|
+
merged into the table's existing fields, not a replacement for them. A field name that collides
|
|
643
|
+
with an existing field, with a file key declared via `.files()`, or with a field injected by
|
|
644
|
+
another plugin attached in the same `.plugin()` call, is a type error at the call site — regardless
|
|
645
|
+
of whether `.files()` or `.plugin()` was called first. `tailor generate` also rejects the same
|
|
646
|
+
collision at runtime, as a backstop for any case a table's static type doesn't otherwise catch.
|
|
647
|
+
|
|
648
|
+
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.
|
|
653
|
+
|
|
654
|
+
To keep the declared type and the runtime implementation in sync, give `Plugin`'s optional third
|
|
655
|
+
type parameter the same shape and use it inside `onTableLoaded`:
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
import { db, type Plugin, type TailorDBField } from "@tailor-platform/sdk";
|
|
659
|
+
|
|
660
|
+
const lifecyclePlugin: Plugin<
|
|
661
|
+
LifecycleTableConfig,
|
|
662
|
+
LifecyclePluginConfig,
|
|
663
|
+
{ status: TailorDBField<{ type: "enum"; array: false }, "PENDING" | "APPROVED" | "REJECTED"> }
|
|
664
|
+
> = {
|
|
665
|
+
id: "@example/lifecycle",
|
|
666
|
+
description: "Derives a status field from a transitions map",
|
|
667
|
+
onTableLoaded(context) {
|
|
668
|
+
return { extends: { fields: { status: db.enum(["PENDING", "APPROVED", "REJECTED"]) } } };
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
```
|
|
672
|
+
|
|
578
673
|
### Resolving plugin-level config from a `Plugin[]` array (declaration merging)
|
|
579
674
|
|
|
580
675
|
`PluginConfig` is already available inside your own plugin's hooks via `context.pluginConfig`.
|
package/docs/plugin/index.md
CHANGED
|
@@ -102,6 +102,11 @@ Plugins can generate:
|
|
|
102
102
|
- **Field Extensions**: Additional fields added to the source table
|
|
103
103
|
- **Output Files**: TypeScript code and other files via generation-time hooks
|
|
104
104
|
|
|
105
|
+
Tables produced by definition-time hooks are validated before registration. This includes
|
|
106
|
+
generated tables and source tables after field extensions are applied. Malformed output stops
|
|
107
|
+
the build with an error that identifies the plugin and relevant table output, without partially
|
|
108
|
+
registering tables from that processing step.
|
|
109
|
+
|
|
105
110
|
Generated files are placed under `.tailor/<plugin-id>/` (the plugin ID is sanitized,
|
|
106
111
|
e.g. `@example/soft-delete` → `example-soft-delete`), such as:
|
|
107
112
|
|
|
@@ -815,25 +815,27 @@ A statement-level test verifies what the script issues, not what it does to data
|
|
|
815
815
|
npm install -D @electric-sql/pglite
|
|
816
816
|
```
|
|
817
817
|
|
|
818
|
-
Create the tables the script touches (matching the shape in the generated `db.ts`), stage rows, then run the script in a transaction
|
|
818
|
+
Create the tables the script touches (matching the shape in the generated `db.ts`), stage rows, then run the script in a transaction. Type the instance with `Unmigrated<Database>` rather than `Database`: `db.ts` types a column the migration makes required as `T | null` on read but `T` on write (and an enum it narrows as the old values on read but the new ones on write), so that `migrate.ts` cannot write what the migration is removing — which would also stop the test from staging the rows the script has to convert. `Unmigrated` lets every column be written with whatever it can still be read as; `main` still receives a `Transaction<Database>`.
|
|
819
819
|
|
|
820
820
|
```typescript
|
|
821
821
|
// migrations/0005/migrate.pglite.test.ts
|
|
822
822
|
import { PGlite } from "@electric-sql/pglite";
|
|
823
823
|
import { sql } from "@tailor-platform/sdk/kysely";
|
|
824
|
-
import { createKyselyPGlite } from "@tailor-platform/sdk/vitest";
|
|
824
|
+
import { createKyselyPGlite, type Unmigrated } from "@tailor-platform/sdk/vitest";
|
|
825
825
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
826
826
|
import type { Database } from "./db";
|
|
827
827
|
import { main } from "./migrate";
|
|
828
828
|
|
|
829
|
-
const db = createKyselyPGlite<Database
|
|
829
|
+
const db = createKyselyPGlite<Unmigrated<Database>>(new PGlite());
|
|
830
830
|
|
|
831
831
|
beforeAll(async () => {
|
|
832
832
|
await sql`
|
|
833
833
|
CREATE TABLE "User" (
|
|
834
834
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
835
835
|
"name" text NOT NULL,
|
|
836
|
-
"email" text
|
|
836
|
+
"email" text,
|
|
837
|
+
"createdAt" timestamptz NOT NULL,
|
|
838
|
+
"updatedAt" timestamptz NOT NULL
|
|
837
839
|
)
|
|
838
840
|
`.execute(db);
|
|
839
841
|
});
|
|
@@ -844,10 +846,14 @@ afterAll(async () => {
|
|
|
844
846
|
|
|
845
847
|
describe("0005 add required email", () => {
|
|
846
848
|
test("backfills null emails and keeps existing ones", async () => {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
849
|
+
const now = new Date();
|
|
850
|
+
await db
|
|
851
|
+
.insertInto("User")
|
|
852
|
+
.values([
|
|
853
|
+
{ name: "a", email: null, createdAt: now, updatedAt: now },
|
|
854
|
+
{ name: "b", email: "b@example.com", createdAt: now, updatedAt: now },
|
|
855
|
+
])
|
|
856
|
+
.execute();
|
|
851
857
|
|
|
852
858
|
await db.transaction().execute((trx) => main(trx));
|
|
853
859
|
|
package/docs/testing.md
CHANGED
|
@@ -24,6 +24,7 @@ For anonymous direct calls:
|
|
|
24
24
|
Platform API mocks under `@tailor-platform/sdk/vitest` (for use with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below):
|
|
25
25
|
|
|
26
26
|
- `mockTailordb` — TailorDB query stubs and call recording
|
|
27
|
+
- `mockTailordbWithPGlite` — TailorDB backed by a real in-memory Postgres (PGlite)
|
|
27
28
|
- `mockWorkflow` — `tailor.workflow` job / wait / resolve mocks
|
|
28
29
|
- `runWorkflowLocally` — local full-chain workflow runner
|
|
29
30
|
- `mockSecretmanager`, `mockAuthconnection`, `mockIdp`, `mockFile`, `mockIconv`, `mockAigateway`, `mockLogger` — corresponding platform API mocks
|
|
@@ -63,7 +64,7 @@ export default defineConfig({
|
|
|
63
64
|
|
|
64
65
|
### Acquiring mocks with `using`
|
|
65
66
|
|
|
66
|
-
Each mock controller (`mockTailordb`, `mockWorkflow`, `mockSecretmanager`, `mockAuthconnection`, `mockIdp`, `mockFile`, `mockIconv`, `mockAigateway`, `mockLogger`) is a **factory function**. Acquire it inside a test with a [`using` declaration](https://github.com/tc39/proposal-explicit-resource-management) — its state is reset automatically when the test scope exits, so you no longer need `beforeEach(() => mock.reset())`:
|
|
67
|
+
Each mock controller (`mockTailordb`, `mockTailordbWithPGlite`, `mockWorkflow`, `mockSecretmanager`, `mockAuthconnection`, `mockIdp`, `mockFile`, `mockIconv`, `mockAigateway`, `mockLogger`) is a **factory function**. Acquire it inside a test with a [`using` declaration](https://github.com/tc39/proposal-explicit-resource-management) — its state is reset automatically when the test scope exits, so you no longer need `beforeEach(() => mock.reset())`:
|
|
67
68
|
|
|
68
69
|
```typescript
|
|
69
70
|
import { mockTailordb } from "@tailor-platform/sdk/vitest";
|
|
@@ -144,6 +145,81 @@ Within one `mockTailordb()` instance, use either `onQuery()` matchers or a direc
|
|
|
144
145
|
|
|
145
146
|
Pass `{ onUnhandled: "error" }` to make an unmatched query fail instead of returning an empty result.
|
|
146
147
|
|
|
148
|
+
#### Real SQL execution with PGlite (`mockTailordbWithPGlite`)
|
|
149
|
+
|
|
150
|
+
Instead of staging responses, back TailorDB with [`@electric-sql/pglite`](https://pglite.dev/) — 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.
|
|
151
|
+
|
|
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.
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
156
|
+
import { mockTailordbWithPGlite } from "@tailor-platform/sdk/vitest";
|
|
157
|
+
import { afterAll, beforeAll, expect, test } from "vitest";
|
|
158
|
+
import { getDB } from "../generated/db";
|
|
159
|
+
import resolver from "./upsertUsers";
|
|
160
|
+
|
|
161
|
+
const pglite = new PGlite();
|
|
162
|
+
|
|
163
|
+
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
|
+
`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterAll(async () => {
|
|
175
|
+
await pglite.close();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("upserts against real rows", async () => {
|
|
179
|
+
using _db = mockTailordbWithPGlite({ namespaces: { "main-db": pglite } });
|
|
180
|
+
const db = getDB("main-db");
|
|
181
|
+
await db
|
|
182
|
+
.insertInto("User")
|
|
183
|
+
.values({ name: "Existing", email: "exists@example.com", age: 40 })
|
|
184
|
+
.execute();
|
|
185
|
+
|
|
186
|
+
const result = await resolver.body({
|
|
187
|
+
input: {
|
|
188
|
+
users: [
|
|
189
|
+
{ name: "Newcomer", email: "new@example.com", age: 22 },
|
|
190
|
+
{ name: "Existing", email: "exists@example.com", age: 41 },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
caller: null,
|
|
194
|
+
invoker: null,
|
|
195
|
+
env: { appName: "Resolver Template", version: 1 },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result).toEqual({ created: 1, updated: 1 });
|
|
199
|
+
const rows = await db.selectFrom("User").selectAll().orderBy("email", "asc").execute();
|
|
200
|
+
expect(rows.map((row) => row.age)).toEqual([41, 22]);
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
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:
|
|
205
|
+
|
|
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
|
+
```
|
|
214
|
+
|
|
215
|
+
PGlite does not apply the TailorDB `.serial()` configuration itself. Match the `start`, `format`, and any limit that the behavior under test relies on.
|
|
216
|
+
|
|
217
|
+
- 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.
|
|
219
|
+
- 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.
|
|
220
|
+
- 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.
|
|
221
|
+
- 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.
|
|
222
|
+
|
|
147
223
|
### Workflow Mock
|
|
148
224
|
|
|
149
225
|
Workflow job `.start()` calls use the platform workflow runtime. Acquire `mockWorkflow()` when you want to provide start responses with `setJobHandler` / `enqueueResult` or assert on `startedJobs`. If no response is configured, the mock throws so missing job mocks fail loudly. Use `job(definition)` or `workflow(definition)` to get a stable, fully typed Vitest mock for one definition:
|
|
@@ -592,7 +668,7 @@ describe("upsertUsers resolver", () => {
|
|
|
592
668
|
});
|
|
593
669
|
```
|
|
594
670
|
|
|
595
|
-
Reach for [`mockTailordb`](#mocking-the-tailordb-client) instead when you want to drive the raw query sequence at the `tailordb.Client` level rather than at the Kysely layer.
|
|
671
|
+
Reach for [`mockTailordb`](#mocking-the-tailordb-client) instead when you want to drive the raw query sequence at the `tailordb.Client` level rather than at the Kysely layer, or [`mockTailordbWithPGlite`](#real-sql-execution-with-pglite-mocktailordbwithpglite) to execute the queries against a real in-memory Postgres.
|
|
596
672
|
|
|
597
673
|
TailorDB migration scripts (`migrate.ts`) are unit-tested the same way: the generated `db.ts` exports the `Database` interface to type the mock, and `tailor tailordb migration script <N> --with-test` scaffolds a ready-to-fill test. To execute a migration script against real rows in an in-memory Postgres, use `createKyselyPGlite` with `@electric-sql/pglite`. See [Testing Migrations Locally](./services/tailordb-migration.md#testing-migrations-locally).
|
|
598
674
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tailor-platform/sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "Tailor Platform SDK - The SDK to work with Tailor Platform",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -155,8 +155,8 @@
|
|
|
155
155
|
"@bufbuild/protovalidate": "1.2.0",
|
|
156
156
|
"@connectrpc/connect": "2.1.2",
|
|
157
157
|
"@connectrpc/connect-node": "2.1.2",
|
|
158
|
-
"@inquirer/core": "12.0.
|
|
159
|
-
"@inquirer/prompts": "8.
|
|
158
|
+
"@inquirer/core": "12.0.1",
|
|
159
|
+
"@inquirer/prompts": "8.7.0",
|
|
160
160
|
"@jridgewell/trace-mapping": "0.3.31",
|
|
161
161
|
"@napi-rs/keyring": "1.3.0",
|
|
162
162
|
"@opentelemetry/api": "1.9.1",
|
|
@@ -165,17 +165,17 @@
|
|
|
165
165
|
"@opentelemetry/sdk-trace-node": "2.10.0",
|
|
166
166
|
"@opentelemetry/semantic-conventions": "1.43.0",
|
|
167
167
|
"@oxc-project/types": "0.147.0",
|
|
168
|
-
"@secretlint/core": "13.0.
|
|
169
|
-
"@secretlint/secretlint-rule-preset-recommend": "13.0.
|
|
168
|
+
"@secretlint/core": "13.0.5",
|
|
169
|
+
"@secretlint/secretlint-rule-preset-recommend": "13.0.5",
|
|
170
170
|
"@standard-schema/spec": "1.1.0",
|
|
171
171
|
"@tailor-platform/function-kysely-tailordb": "0.1.3",
|
|
172
|
-
"@toiroakr/lines-db": "0.12.
|
|
172
|
+
"@toiroakr/lines-db": "0.12.5",
|
|
173
173
|
"@toiroakr/read-multiline": "0.4.1",
|
|
174
174
|
"@urql/core": "6.0.3",
|
|
175
175
|
"amaro": "1.1.11",
|
|
176
176
|
"confbox": "0.2.4",
|
|
177
177
|
"date-fns": "4.4.0",
|
|
178
|
-
"es-toolkit": "1.
|
|
178
|
+
"es-toolkit": "1.52.0",
|
|
179
179
|
"find-up-simple": "1.0.1",
|
|
180
180
|
"get-east-asian-width": "1.6.0",
|
|
181
181
|
"get-tsconfig": "4.14.3",
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
"pgsql-ast-parser": "12.0.2",
|
|
192
192
|
"pkg-types": "2.3.1",
|
|
193
193
|
"politty": "0.11.9",
|
|
194
|
-
"rolldown": "1.2.
|
|
194
|
+
"rolldown": "1.2.6",
|
|
195
195
|
"semver": "7.8.5",
|
|
196
196
|
"sql-highlight": "6.1.0",
|
|
197
197
|
"std-env": "4.2.0",
|
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
"scripts": {
|
|
243
243
|
"test": "vitest",
|
|
244
244
|
"test:unit": "vitest --project=unit*",
|
|
245
|
-
"test:e2e": "vitest --project=e2e",
|
|
245
|
+
"test:e2e": "vitest --project=e2e*",
|
|
246
246
|
"test:coverage": "vitest --coverage",
|
|
247
247
|
"docs:check": "vitest run --project=unit* src/cli/docs.test.ts",
|
|
248
248
|
"docs:update": "POLITTY_DOCS_UPDATE=true vitest run --project=unit* src/cli/docs.test.ts",
|