From 3a3404544f7ac1eabc20fb9cead6f25918604be0 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 17:06:23 +0900 Subject: [PATCH 01/18] chore: regenerate example generator fixtures --- example/tests/fixtures/expected/db.ts | 15 ++++++++++++++- .../expected/seed/data/Customer.schema.ts | 4 ++-- .../fixtures/expected/seed/data/Event.schema.ts | 4 ++-- .../fixtures/expected/seed/data/Invoice.schema.ts | 4 ++-- .../expected/seed/data/NestedProfile.schema.ts | 4 ++-- .../expected/seed/data/ProductBundle.jsonl | 0 .../expected/seed/data/ProductBundle.schema.ts | 15 +++++++++++++++ .../expected/seed/data/PurchaseOrder.schema.ts | 4 ++-- .../expected/seed/data/SalesOrder.schema.ts | 4 ++-- .../expected/seed/data/Supplier.schema.ts | 4 ++-- .../fixtures/expected/seed/data/User.schema.ts | 4 ++-- .../fixtures/expected/seed/data/UserLog.schema.ts | 4 ++-- .../expected/seed/data/UserSetting.schema.ts | 4 ++-- 13 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 example/tests/fixtures/expected/seed/data/ProductBundle.jsonl create mode 100644 example/tests/fixtures/expected/seed/data/ProductBundle.schema.ts diff --git a/example/tests/fixtures/expected/db.ts b/example/tests/fixtures/expected/db.ts index e769dbf3a3..6681543067 100644 --- a/example/tests/fixtures/expected/db.ts +++ b/example/tests/fixtures/expected/db.ts @@ -24,7 +24,7 @@ export interface Namespace { postalCode: string; address: string | null; city: string | null; - fullAddress: Generated; + fullAddress: string; state: string; createdAt: Generated; updatedAt: Generated; @@ -60,6 +60,19 @@ export interface Namespace { updatedAt: Generated; } + ProductBundle: { + id: Generated; + name: string; + label: string | null; + items: { + productName: string; + qty: number; + unitPrice: number; + }[]; + createdAt: Generated; + updatedAt: Generated; + } + PurchaseOrder: { id: Generated; supplierID: string; diff --git a/example/tests/fixtures/expected/seed/data/Customer.schema.ts b/example/tests/fixtures/expected/seed/data/Customer.schema.ts index 11ecb751c8..9469e08683 100644 --- a/example/tests/fixtures/expected/seed/data/Customer.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Customer.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { customer } from "../../../../../tailordb/customer"; const schemaType = t.object({ - ...customer.pickFields(["id","fullAddress","createdAt","updatedAt"], { optional: true }), - ...customer.omitFields(["id","fullAddress","createdAt","updatedAt"]), + ...customer.pickFields(["id"], { optional: true }), + ...customer.omitFields(["id"]), }); const hook = createTailorDBHook(customer); diff --git a/example/tests/fixtures/expected/seed/data/Event.schema.ts b/example/tests/fixtures/expected/seed/data/Event.schema.ts index 50a3f4d02d..4426bd1cea 100644 --- a/example/tests/fixtures/expected/seed/data/Event.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Event.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { event } from "../../../../../analyticsdb/event"; const schemaType = t.object({ - ...event.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...event.omitFields(["id","createdAt","updatedAt"]), + ...event.pickFields(["id"], { optional: true }), + ...event.omitFields(["id"]), }); const hook = createTailorDBHook(event); diff --git a/example/tests/fixtures/expected/seed/data/Invoice.schema.ts b/example/tests/fixtures/expected/seed/data/Invoice.schema.ts index a2a7cfc499..6bbece5a95 100644 --- a/example/tests/fixtures/expected/seed/data/Invoice.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Invoice.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { invoice } from "../../../../../tailordb/invoice"; const schemaType = t.object({ - ...invoice.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...invoice.omitFields(["id","createdAt","updatedAt","invoiceNumber","sequentialId"]), + ...invoice.pickFields(["id"], { optional: true }), + ...invoice.omitFields(["id","invoiceNumber","sequentialId"]), }); const hook = createTailorDBHook(invoice); diff --git a/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts b/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts index 2424ec58c8..918690ed83 100644 --- a/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts +++ b/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { nestedProfile } from "../../../../../tailordb/nested"; const schemaType = t.object({ - ...nestedProfile.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...nestedProfile.omitFields(["id","createdAt","updatedAt"]), + ...nestedProfile.pickFields(["id"], { optional: true }), + ...nestedProfile.omitFields(["id"]), }); const hook = createTailorDBHook(nestedProfile); diff --git a/example/tests/fixtures/expected/seed/data/ProductBundle.jsonl b/example/tests/fixtures/expected/seed/data/ProductBundle.jsonl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/example/tests/fixtures/expected/seed/data/ProductBundle.schema.ts b/example/tests/fixtures/expected/seed/data/ProductBundle.schema.ts new file mode 100644 index 0000000000..0c391d9600 --- /dev/null +++ b/example/tests/fixtures/expected/seed/data/ProductBundle.schema.ts @@ -0,0 +1,15 @@ +import { t } from "@tailor-platform/sdk"; +import { defineSchema } from "@tailor-platform/sdk/seed"; +import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/test"; +import { productBundle } from "../../../../../tailordb/productBundle"; + +const schemaType = t.object({ + ...productBundle.pickFields(["id"], { optional: true }), + ...productBundle.omitFields(["id"]), +}); + +const hook = createTailorDBHook(productBundle); + +export const schema = defineSchema( + createStandardSchema(schemaType, hook), +); diff --git a/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts b/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts index c733271327..8f3ea12c6b 100644 --- a/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts +++ b/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { purchaseOrder } from "../../../../../tailordb/purchaseOrder"; const schemaType = t.object({ - ...purchaseOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...purchaseOrder.omitFields(["id","createdAt","updatedAt"]), + ...purchaseOrder.pickFields(["id"], { optional: true }), + ...purchaseOrder.omitFields(["id"]), }); const hook = createTailorDBHook(purchaseOrder); diff --git a/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts b/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts index 37a210130c..0d4723147c 100644 --- a/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts +++ b/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { salesOrder } from "../../../../../tailordb/salesOrder"; const schemaType = t.object({ - ...salesOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...salesOrder.omitFields(["id","createdAt","updatedAt"]), + ...salesOrder.pickFields(["id"], { optional: true }), + ...salesOrder.omitFields(["id"]), }); const hook = createTailorDBHook(salesOrder); diff --git a/example/tests/fixtures/expected/seed/data/Supplier.schema.ts b/example/tests/fixtures/expected/seed/data/Supplier.schema.ts index db46278031..0ed75d758b 100644 --- a/example/tests/fixtures/expected/seed/data/Supplier.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Supplier.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { supplier } from "../../../../../tailordb/supplier"; const schemaType = t.object({ - ...supplier.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...supplier.omitFields(["id","createdAt","updatedAt"]), + ...supplier.pickFields(["id"], { optional: true }), + ...supplier.omitFields(["id"]), }); const hook = createTailorDBHook(supplier); diff --git a/example/tests/fixtures/expected/seed/data/User.schema.ts b/example/tests/fixtures/expected/seed/data/User.schema.ts index 21342b67d1..e524c5b94f 100644 --- a/example/tests/fixtures/expected/seed/data/User.schema.ts +++ b/example/tests/fixtures/expected/seed/data/User.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { user } from "../../../../../tailordb/user"; const schemaType = t.object({ - ...user.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...user.omitFields(["id","createdAt","updatedAt"]), + ...user.pickFields(["id"], { optional: true }), + ...user.omitFields(["id"]), }); const hook = createTailorDBHook(user); diff --git a/example/tests/fixtures/expected/seed/data/UserLog.schema.ts b/example/tests/fixtures/expected/seed/data/UserLog.schema.ts index 556c81e96b..be94342b40 100644 --- a/example/tests/fixtures/expected/seed/data/UserLog.schema.ts +++ b/example/tests/fixtures/expected/seed/data/UserLog.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { userLog } from "../../../../../tailordb/userLog"; const schemaType = t.object({ - ...userLog.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...userLog.omitFields(["id","createdAt","updatedAt"]), + ...userLog.pickFields(["id"], { optional: true }), + ...userLog.omitFields(["id"]), }); const hook = createTailorDBHook(userLog); diff --git a/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts b/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts index 87fec2df1f..a4e9dd9041 100644 --- a/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts +++ b/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { userSetting } from "../../../../../tailordb/userSetting"; const schemaType = t.object({ - ...userSetting.pickFields(["id","createdAt","updatedAt"], { optional: true }), - ...userSetting.omitFields(["id","createdAt","updatedAt"]), + ...userSetting.pickFields(["id"], { optional: true }), + ...userSetting.omitFields(["id"]), }); const hook = createTailorDBHook(userSetting); From 817454fff35e4093bce5fdcb9e1fcda8bbd1d7ef Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 17:06:49 +0900 Subject: [PATCH 02/18] feat(sdk)!: extract seed execution into the sdk-plugin-seed CLI plugin seedPlugin no longer generates the exec.mjs seed runner; it keeps generating the JSONL data and lines-db schema files. The new @tailor-platform/sdk-plugin-seed package ships a tailor-seed executable that the CLI dispatches to as `tailor seed apply` / `tailor seed validate`, with the same options the generated script had. Instead of baking namespaces, dependency order, and IdP scripts into a generated file, the plugin loads them from tailor.config.ts at run time through the new loadSeedContext export in @tailor-platform/sdk/cli. - @tailor-platform/sdk/cli: add loadSeedContext (+ SeedContext types), type SeedData as JSON objects, and accept plain-object executeScript invokers via ScriptInvoker - host CLI: register the seed plugin in KNOWN_PLUGIN_PACKAGES for the install hint - example / create-sdk generators template: replace exec.mjs usage with tailor seed scripts and a devDependency on the plugin; prepare-templates.js now also rewrites the plugin version - docs: CLI plugins section, configuration.md, and a codemod-less v2/seed-exec-to-cli-plugin migration entry - CI: publish the plugin on pkg.pr.new and cover it in changeset-check --- .changeset/add-seed-plugin-package.md | 5 + .changeset/extract-seed-plugin.md | 5 + .github/workflows/changeset-check.yml | 1 + .github/workflows/pkg-pr-new.yml | 6 +- example/package.json | 5 +- example/seed/exec.mjs | 686 ---------------- example/tests/fixtures/expected/seed/exec.mjs | 684 ---------------- .../create-sdk/scripts/prepare-templates.js | 39 +- .../create-sdk/templates/generators/README.md | 2 +- .../templates/generators/package.json | 3 + .../templates/generators/src/seed/exec.mjs | 485 ----------- packages/sdk-codemod/src/registry.ts | 33 + packages/sdk-plugin-seed/.oxlintrc.json | 64 ++ packages/sdk-plugin-seed/README.md | 49 ++ packages/sdk-plugin-seed/package.json | 48 ++ packages/sdk-plugin-seed/src/apply.ts | 410 ++++++++++ packages/sdk-plugin-seed/src/entities.test.ts | 106 +++ packages/sdk-plugin-seed/src/entities.ts | 72 ++ packages/sdk-plugin-seed/src/index.ts | 54 ++ packages/sdk-plugin-seed/src/jsonl.test.ts | 39 + packages/sdk-plugin-seed/src/jsonl.ts | 31 + packages/sdk-plugin-seed/src/shared/args.ts | 119 +++ .../sdk-plugin-seed/src/shared/command.ts | 9 + packages/sdk-plugin-seed/src/shared/logger.ts | 97 +++ .../sdk-plugin-seed/src/topo-sort.test.ts | 25 + packages/sdk-plugin-seed/src/topo-sort.ts | 27 + packages/sdk-plugin-seed/src/validate.ts | 40 + packages/sdk-plugin-seed/tsconfig.json | 11 + packages/sdk-plugin-seed/tsdown.config.ts | 15 + packages/sdk-plugin-seed/vitest.config.ts | 19 + packages/sdk/docs/cli-reference.md | 7 + packages/sdk/docs/cli-reference.template.md | 7 + packages/sdk/docs/configuration.md | 2 +- packages/sdk/docs/migration/v2.md | 40 + .../sdk/src/cli/commands/generate/service.ts | 25 +- packages/sdk/src/cli/lib.ts | 15 +- packages/sdk/src/cli/shared/auth-input.ts | 29 + packages/sdk/src/cli/shared/plugin.ts | 1 + .../sdk/src/cli/shared/script-executor.ts | 11 +- packages/sdk/src/cli/shared/seed-chunker.ts | 5 +- .../sdk/src/cli/shared/seed-context.test.ts | 140 ++++ packages/sdk/src/cli/shared/seed-context.ts | 94 +++ .../sdk/src/cli/shared/tailordb-namespaces.ts | 40 +- packages/sdk/src/plugin/builtin/seed/index.ts | 759 +----------------- .../builtin/seed/seed-type-processor.ts | 41 + .../builtin/seed/template-literal.test.ts | 22 - .../plugin/builtin/seed/template-literal.ts | 8 - pnpm-lock.yaml | 46 ++ 48 files changed, 1795 insertions(+), 2686 deletions(-) create mode 100644 .changeset/add-seed-plugin-package.md create mode 100644 .changeset/extract-seed-plugin.md delete mode 100644 example/seed/exec.mjs delete mode 100644 example/tests/fixtures/expected/seed/exec.mjs delete mode 100644 packages/create-sdk/templates/generators/src/seed/exec.mjs create mode 100644 packages/sdk-plugin-seed/.oxlintrc.json create mode 100644 packages/sdk-plugin-seed/README.md create mode 100644 packages/sdk-plugin-seed/package.json create mode 100644 packages/sdk-plugin-seed/src/apply.ts create mode 100644 packages/sdk-plugin-seed/src/entities.test.ts create mode 100644 packages/sdk-plugin-seed/src/entities.ts create mode 100644 packages/sdk-plugin-seed/src/index.ts create mode 100644 packages/sdk-plugin-seed/src/jsonl.test.ts create mode 100644 packages/sdk-plugin-seed/src/jsonl.ts create mode 100644 packages/sdk-plugin-seed/src/shared/args.ts create mode 100644 packages/sdk-plugin-seed/src/shared/command.ts create mode 100644 packages/sdk-plugin-seed/src/shared/logger.ts create mode 100644 packages/sdk-plugin-seed/src/topo-sort.test.ts create mode 100644 packages/sdk-plugin-seed/src/topo-sort.ts create mode 100644 packages/sdk-plugin-seed/src/validate.ts create mode 100644 packages/sdk-plugin-seed/tsconfig.json create mode 100644 packages/sdk-plugin-seed/tsdown.config.ts create mode 100644 packages/sdk-plugin-seed/vitest.config.ts create mode 100644 packages/sdk/src/cli/shared/auth-input.ts create mode 100644 packages/sdk/src/cli/shared/seed-context.test.ts create mode 100644 packages/sdk/src/cli/shared/seed-context.ts delete mode 100644 packages/sdk/src/plugin/builtin/seed/template-literal.test.ts delete mode 100644 packages/sdk/src/plugin/builtin/seed/template-literal.ts diff --git a/.changeset/add-seed-plugin-package.md b/.changeset/add-seed-plugin-package.md new file mode 100644 index 0000000000..56d3b7188d --- /dev/null +++ b/.changeset/add-seed-plugin-package.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-plugin-seed": minor +--- + +New Tailor CLI plugin providing the `tailor seed` commands (`apply`, `validate`), extracted from the `exec.mjs` script that `seedPlugin` used to generate. `tailor seed apply` seeds TailorDB (and IdP `_User`) data from the generated JSONL files with the same options as the old script (`--machine-user`, `--namespace`, `--skip-idp`, `--truncate`, `--yes`, type-name arguments), and `tailor seed validate` validates the JSONL data against the generated schemas. The machine user and data location now come from the seedPlugin options in `tailor.config.ts` at run time. diff --git a/.changeset/extract-seed-plugin.md b/.changeset/extract-seed-plugin.md new file mode 100644 index 0000000000..f03da3c36f --- /dev/null +++ b/.changeset/extract-seed-plugin.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +`seedPlugin` no longer generates the `exec.mjs` seed runner. Seeding and validation move to the `tailor seed` commands provided by the `@tailor-platform/sdk-plugin-seed` CLI plugin: install it with `npm install -D @tailor-platform/sdk-plugin-seed`, replace `node /exec.mjs` with `tailor seed apply` and `node /exec.mjs validate` with `tailor seed validate`, and delete the stale generated `exec.mjs`. Seed data and schema generation (`data/*.jsonl`, `data/*.schema.ts`) is unchanged. Because the plugin reads the config at run time, `machineUserName` changes in seedPlugin options now take effect without regenerating. `@tailor-platform/sdk/cli` gains `loadSeedContext` (and `SeedContext` types) for this, `SeedData` is now JSON-typed, and `executeScript` accepts a plain object `invoker` (`ScriptInvoker`). diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index faf1602382..8fc33397c4 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -41,6 +41,7 @@ jobs: packages: - 'packages/sdk/**' - 'packages/create-sdk/**' + - 'packages/sdk-plugin-seed/**' - 'packages/sdk-plugin-tailordb-erd/**' - 'packages/tailor-proto/**' diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 7af845ccd5..63824d96db 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -51,12 +51,15 @@ jobs: SHORT_SHA="${SHA:0:7}" SDK_URL="https://pkg.pr.new/@tailor-platform/sdk@${SHORT_SHA}" CREATE_SDK_URL="https://pkg.pr.new/@tailor-platform/create-sdk@${SHORT_SHA}" + SEED_PLUGIN_URL="https://pkg.pr.new/@tailor-platform/sdk-plugin-seed@${SHORT_SHA}" { echo "sdk_url=$SDK_URL" echo "create_sdk_url=$CREATE_SDK_URL" + echo "seed_plugin_url=$SEED_PLUGIN_URL" } >> "$GITHUB_OUTPUT" echo "SDK preview: $SDK_URL" echo "create-sdk preview: $CREATE_SDK_URL" + echo "seed plugin preview: $SEED_PLUGIN_URL" # `prepare-templates.js` rewrites template manifests but leaves the # lockfile untouched; pnpm 11's default `verifyDepsBeforeRun: install` @@ -65,12 +68,13 @@ jobs: - name: Build create-sdk with pkg.pr.new SDK env: TAILOR_TEMPLATE_SDK_VERSION: ${{ steps.urls.outputs.sdk_url }} + TAILOR_TEMPLATE_SEED_PLUGIN_VERSION: ${{ steps.urls.outputs.seed_plugin_url }} run: | node packages/create-sdk/scripts/prepare-templates.js pnpm --config.verify-deps-before-run=false --filter @tailor-platform/create-sdk build - name: Publish to pkg.pr.new - run: pnpm --config.verify-deps-before-run=false exec pkg-pr-new publish --pnpm --compact --commentWithSha --packageManager=pnpm "packages/sdk" "packages/create-sdk" "packages/sdk-plugin-tailordb-erd" # zizmor: ignore[use-trusted-publishing] + run: pnpm --config.verify-deps-before-run=false exec pkg-pr-new publish --pnpm --compact --commentWithSha --packageManager=pnpm "packages/sdk" "packages/create-sdk" "packages/sdk-plugin-seed" "packages/sdk-plugin-tailordb-erd" # zizmor: ignore[use-trusted-publishing] init-smoke: needs: [publish] diff --git a/example/package.json b/example/package.json index 65b3637406..3841dfa324 100644 --- a/example/package.json +++ b/example/package.json @@ -15,9 +15,9 @@ "test:generator:update-expects": "tsx tests/scripts/generate_files.ts expected", "test:e2e": "vitest --project e2e", "migration:e2e": "tsx tests/scripts/migration_e2e.ts", - "seed:validate": "node ./seed/exec.mjs validate", + "seed:validate": "tailor seed validate", "seed:truncate": "tailor tailordb truncate -a", - "seed": "node ./seed/exec.mjs", + "seed": "tailor seed apply", "analyze:bundle": "tsx tests/scripts/analyze_minified_size.ts", "lint": "oxlint --type-aware .", "lint:fix": "oxlint --type-aware . --fix", @@ -34,6 +34,7 @@ "@bufbuild/protobuf": "2.12.1", "@connectrpc/connect": "2.1.2", "@connectrpc/connect-node": "2.1.2", + "@tailor-platform/sdk-plugin-seed": "workspace:^", "@tailor-platform/sdk-plugin-tailordb-erd": "workspace:^", "@types/node": "24.13.3", "@typescript/native-preview": "7.0.0-dev.20260707.2", diff --git a/example/seed/exec.mjs b/example/seed/exec.mjs deleted file mode 100644 index 0e065fa92b..0000000000 --- a/example/seed/exec.mjs +++ /dev/null @@ -1,686 +0,0 @@ -/** - * @generated - * This file is auto-generated by @tailor-platform/sdk's seedPlugin. - * Do not edit by hand: changes will be overwritten on the next `sdk generate`. - */ -import { readFileSync } from "node:fs"; -import { join, isAbsolute } from "node:path"; -import { parseArgs, styleText } from "node:util"; -import { createInterface } from "node:readline"; -import { - show, - truncate, - bundleSeedScript, - chunkSeedData, - executeScript, - initOperatorClient, - loadAccessToken, - loadWorkspaceId, -} from "@tailor-platform/sdk/cli"; - -// Handle "validate" subcommand before parseArgs -const subcommand = process.argv[2]; -if (subcommand === "validate") { - const { validateSeedData } = await import("@tailor-platform/sdk/seed"); - const validateArgs = parseArgs({ - args: process.argv.slice(3), - options: { - verbose: { type: "boolean", short: "v", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, - }); - - if (validateArgs.values.help) { - console.log(` -Usage: node exec.mjs validate [options] [path] - -Validate JSONL seed data against schema definitions. - -Arguments: - path File or directory to validate (default: ./data) - -Options: - -v, --verbose Show verbose error output - -h, --help Show help - -Examples: - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - node exec.mjs validate -v # Verbose error output - `); - process.exit(0); - } - - const configDir = import.meta.dirname; - const targetPath = validateArgs.positionals[0] || join(configDir, "data"); - const resolvedPath = isAbsolute(targetPath) ? targetPath : join(process.cwd(), targetPath); - - try { - const result = await validateSeedData({ path: resolvedPath, verbose: validateArgs.values.verbose }); - if (result.output) console.log(result.output); - if (!result.valid) { - console.error(result.error); - process.exit(1); - } - process.exit(0); - } catch (error) { - console.error(styleText("red", `Error: ${error instanceof Error ? error.message : String(error)}`)); - process.exit(1); - } -} - -// Parse command-line arguments -const { values, positionals } = parseArgs({ - options: { - "machine-user": { type: "string", short: "m" }, - namespace: { type: "string", short: "n" }, - "skip-idp": { type: "boolean", default: false }, - truncate: { type: "boolean", default: false }, - yes: { type: "boolean", default: false }, - profile: { type: "string", short: "p" }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, -}); - -if (values.help) { - console.log(` -Usage: node exec.mjs [command] [options] [types...] - -Commands: - validate [path] Validate seed data against schema (default: ./data) - -Options: - -m, --machine-user Machine user name for authentication (required if not configured) - -n, --namespace Process all types in specified namespace (excludes _User) - --skip-idp Skip IdP user (_User) entity - --truncate Truncate tables before seeding - --yes Skip confirmation prompts (for truncate) - -p, --profile Workspace profile name - -h, --help Show help - -Examples: - node exec.mjs -m admin # Process all types with machine user - node exec.mjs --namespace # Process tailordb namespace only (no _User) - node exec.mjs User Order # Process specific types only - node exec.mjs --skip-idp # Process all except _User - node exec.mjs --truncate # Truncate all tables, then seed all - node exec.mjs --truncate --yes # Truncate all tables without confirmation, then seed all - node exec.mjs --truncate --namespace # Truncate tailordb, then seed tailordb - node exec.mjs --truncate User Order # Truncate User and Order, then seed them - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - `); - process.exit(0); -} - -// Helper function to prompt for y/n confirmation -const promptConfirmation = (question) => { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(styleText("yellow", question), (answer) => { - rl.close(); - resolve(answer.toLowerCase().trim()); - }); - }); -}; - -const configDir = import.meta.dirname; -const configPath = join(configDir, "../tailor.config.ts"); - -// Determine machine user name (CLI argument takes precedence over config default) -const defaultMachineUser = "manager-machine-user"; -const machineUserName = values["machine-user"] || defaultMachineUser; - -if (!machineUserName) { - console.error(styleText("red", "Error: Machine user name is required.")); - console.error(styleText("yellow", "Specify --machine-user or configure machineUserName in generator options.")); - process.exit(1); -} - -// Entity configuration -const namespaceEntities = { - "tailordb": [ - "Customer", - "Invoice", - "NestedProfile", - "ProductBundle", - "PurchaseOrder", - "SalesOrder", - "SalesOrderCreated", - "Selfie", - "Supplier", - "User", - "UserLog", - "UserSetting", - ], - "analyticsdb": [ - "Event", - ] -}; -const namespaceDeps = { - "tailordb": { - "Customer": [], - "Invoice": ["SalesOrder"], - "NestedProfile": [], - "ProductBundle": [], - "PurchaseOrder": ["Supplier"], - "SalesOrder": ["Customer", "User"], - "SalesOrderCreated": [], - "Selfie": [], - "Supplier": [], - "User": [], - "UserLog": ["User"], - "UserSetting": ["User"] - }, - "analyticsdb": { - "Event": [] - } -}; -const namespaceSelfRefTypes = { - "tailordb": ["Selfie"], - "analyticsdb": [] -}; -const entities = Object.values(namespaceEntities).flat(); -const hasIdpUser = true; - -// Determine which entities to process -let entitiesToProcess = null; - -const hasNamespace = !!values.namespace; -const hasTypes = positionals.length > 0; -const skipIdp = values["skip-idp"]; - -// Validate mutually exclusive options -const optionCount = [hasNamespace, hasTypes].filter(Boolean).length; -if (optionCount > 1) { - console.error(styleText("red", "Error: Options --namespace and type names are mutually exclusive.")); - process.exit(1); -} - -// --skip-idp and --namespace are redundant (namespace already excludes _User) -if (skipIdp && hasNamespace) { - console.warn(styleText("yellow", "Warning: --skip-idp is redundant with --namespace (namespace filtering already excludes _User).")); -} - -// Filter by namespace (automatically excludes _User as it has no namespace) -if (hasNamespace) { - const namespace = values.namespace; - entitiesToProcess = namespaceEntities[namespace]; - - if (!entitiesToProcess || entitiesToProcess.length === 0) { - console.error(styleText("red", `Error: No entities found in namespace "${namespace}"`)); - console.error(styleText("yellow", `Available namespaces: ${Object.keys(namespaceEntities).join(", ")}`)); - process.exit(1); - } - - console.log(styleText("cyan", `Filtering by namespace: ${namespace}`)); - console.log(styleText("dim", `Entities: ${entitiesToProcess.join(", ")}`)); -} - -// Filter by specific types -if (hasTypes) { - const requestedTypes = positionals; - const notFoundTypes = []; - const allTypes = hasIdpUser ? [...entities, "_User"] : entities; - - entitiesToProcess = requestedTypes.filter((type) => { - if (!allTypes.includes(type)) { - notFoundTypes.push(type); - return false; - } - return true; - }); - - if (notFoundTypes.length > 0) { - console.error(styleText("red", `Error: The following types were not found: ${notFoundTypes.join(", ")}`)); - console.error(styleText("yellow", `Available types: ${allTypes.join(", ")}`)); - process.exit(1); - } - - console.log(styleText("cyan", `Filtering by types: ${entitiesToProcess.join(", ")}`)); -} - -// Apply --skip-idp filter -if (skipIdp) { - if (entitiesToProcess) { - entitiesToProcess = entitiesToProcess.filter((entity) => entity !== "_User"); - } else { - entitiesToProcess = entities.filter((entity) => entity !== "_User"); - } -} - -// Get application info -const appInfo = await show({ configPath, profile: values.profile }); -const authNamespace = appInfo.auth; - -// Initialize operator client (once for all namespaces) -const accessToken = await loadAccessToken({ profile: values.profile }); -const workspaceId = await loadWorkspaceId({ profile: values.profile }); -const operatorClient = await initOperatorClient(accessToken); - - // Truncate _User via tailor.idp.Client (server-side) - const truncateIdpUser = async () => { - console.log(styleText("cyan", "Truncating _User via tailor.idp.Client...")); - - const idpTruncateCode = /* js */`export async function main() { - const client = new tailor.idp.Client({ namespace: "my-idp" }); - const errors = []; - let deleted = 0; - - // List all users with pagination - let after = undefined; - const allUsers = []; - do { - const response = await client.users(after ? { after } : undefined); - allUsers.push(...(response.users || [])); - after = response.nextPageToken; - } while (after); - - console.log(\`Found \${allUsers.length} IDP users to delete\`); - - for (const user of allUsers) { - try { - await client.deleteUser(user.id); - deleted++; - console.log(\`[_User] Deleted \${deleted}/\${allUsers.length}: \${user.name}\`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(\`User \${user.id} (\${user.name}): \${message}\`); - console.error(\`[_User] Delete failed for \${user.name}: \${message}\`); - } - } - - return { - success: errors.length === 0, - deleted, - total: allUsers.length, - errors, - }; -}`; - - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: "truncate-idp-user.ts", - code: idpTruncateCode, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - if (result.logs) { - for (const line of result.logs.split("\n").filter(Boolean)) { - console.log(styleText("dim", ` ${line}`)); - } - } - - if (result.success) { - let parsed; - try { - parsed = JSON.parse(result.result || "{}"); - } catch (e) { - console.error(styleText("red", ` ✗ Failed to parse truncation result: ${e.message}`)); - return { success: false }; - } - - if (parsed.deleted !== undefined) { - console.log(styleText("green", ` ✓ _User: ${parsed.deleted} users deleted`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - for (const err of errors) { - console.error(styleText("red", ` ✗ ${err}`)); - } - return { success: false }; - } - - return { success: true }; - } else { - console.error(styleText("red", ` ✗ Truncation failed: ${result.error}`)); - return { success: false }; - } - }; - -// Truncate tables if requested -if (values.truncate) { - const answer = values.yes ? "y" : await promptConfirmation("Are you sure you want to truncate? (y/n): "); - if (answer !== "y") { - console.log(styleText("yellow", "Truncate cancelled.")); - process.exit(0); - } - - console.log(styleText("cyan", "Truncating tables...")); - - try { - if (hasNamespace) { - await truncate({ - configPath, - profile: values.profile, - namespace: values.namespace, - }); - } else if (hasTypes) { - const typesToTruncate = entitiesToProcess.filter((t) => t !== "_User"); - if (typesToTruncate.length > 0) { - await truncate({ - configPath, - profile: values.profile, - types: typesToTruncate, - }); - } else { - console.log(styleText("dim", "No TailorDB types to truncate (only _User was specified).")); - } - } else { - await truncate({ - configPath, - profile: values.profile, - all: true, - }); - } - } catch (error) { - console.error(styleText("red", `Truncate failed: ${error.message}`)); - process.exit(1); - } - - // Truncate _User if applicable - const shouldTruncateUser = !skipIdp && !hasNamespace && (!hasTypes || entitiesToProcess.includes("_User")); - if (hasIdpUser && shouldTruncateUser) { - const truncResult = await truncateIdpUser(); - if (!truncResult.success) { - console.error(styleText("red", "IDP user truncation failed.")); - process.exit(1); - } - } - - console.log(styleText("green", "Truncate completed.")); -} - -console.log(styleText("cyan", "\nStarting seed data generation...")); -if (skipIdp) { - console.log(styleText("dim", ` Skipping IdP user (_User)`)); -} - -// Load seed data from JSONL files -const loadSeedData = (dataDir, typeNames) => { - const data = {}; - for (const typeName of typeNames) { - const jsonlPath = join(dataDir, `${typeName}.jsonl`); - try { - const content = readFileSync(jsonlPath, "utf-8").trim(); - if (content) { - data[typeName] = content.split("\n").map((line) => JSON.parse(line)); - } else { - data[typeName] = []; - } - } catch (error) { - if (error.code === "ENOENT") { - data[typeName] = []; - } else { - throw error; - } - } - } - return data; -}; - -// Topological sort for dependency order -const topologicalSort = (types, deps) => { - const visited = new Set(); - const result = []; - - const visit = (type) => { - if (visited.has(type)) return; - visited.add(type); - const typeDeps = deps[type] || []; - for (const dep of typeDeps) { - if (types.includes(dep)) { - visit(dep); - } - } - result.push(type); - }; - - for (const type of types) { - visit(type); - } - return result; -}; - -// Seed TailorDB types via testExecScript -const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) => { - const dataDir = join(configDir, "data"); - const sortedTypes = topologicalSort(typesToSeed, deps); - const data = loadSeedData(dataDir, sortedTypes); - - // Skip if no data - const typesWithData = sortedTypes.filter((t) => data[t] && data[t].length > 0); - if (typesWithData.length === 0) { - console.log(styleText("dim", ` [${namespace}] No data to seed`)); - return { success: true, processed: {} }; - } - - console.log(styleText("cyan", ` [${namespace}] Seeding ${typesWithData.length} types via Kysely batch insert...`)); - - // Bundle seed script - const bundled = await bundleSeedScript(namespace, typesWithData); - - // Chunk seed data to fit within gRPC message size limits - const chunks = chunkSeedData({ - data, - order: sortedTypes, - codeByteSize: new TextEncoder().encode(bundled.bundledCode).length, - }); - - if (chunks.length === 0) { - console.log(styleText("dim", ` [${namespace}] No data to seed`)); - return { success: true, processed: {} }; - } - - if (chunks.length > 1) { - console.log(styleText("dim", ` Split into ${chunks.length} chunks`)); - } - - const allProcessed = {}; - let hasError = false; - const allErrors = []; - - for (const chunk of chunks) { - if (chunks.length > 1) { - console.log(styleText("dim", ` Chunk ${chunk.index + 1}/${chunk.total}: ${chunk.order.join(", ")}`)); - } - - // Execute seed script for this chunk - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: `seed-${namespace}.ts`, - code: bundled.bundledCode, - arg: { data: chunk.data, order: chunk.order, selfRefTypes }, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - // Parse result and display logs - if (result.logs) { - for (const line of result.logs.split("\n").filter(Boolean)) { - console.log(styleText("dim", ` ${line}`)); - } - } - - if (result.success) { - let parsed; - try { - const parsedResult = JSON.parse(result.result || "{}"); - parsed = parsedResult && typeof parsedResult === "object" ? parsedResult : {}; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(styleText("red", ` ✗ Failed to parse seed result: ${message}`)); - hasError = true; - allErrors.push(message); - continue; - } - - const processed = parsed.processed || {}; - for (const [type, count] of Object.entries(processed)) { - allProcessed[type] = (allProcessed[type] || 0) + count; - console.log(styleText("green", ` ✓ ${type}: ${count} rows inserted`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - const errorMessage = - errors.length > 0 ? errors.join("\n ") : "Seed script reported failure"; - console.error(styleText("red", ` ✗ Seed failed:\n ${errorMessage}`)); - hasError = true; - allErrors.push(errorMessage); - } - } else { - console.error(styleText("red", ` ✗ Seed failed: ${result.error}`)); - hasError = true; - allErrors.push(result.error); - } - } - - if (hasError) { - return { success: false, error: allErrors.join("\n") }; - } - return { success: true, processed: allProcessed }; -}; - - // Seed _User via tailor.idp.Client (server-side) - const seedIdpUser = async () => { - console.log(styleText("cyan", " Seeding _User via tailor.idp.Client...")); - const dataDir = join(configDir, "data"); - const data = loadSeedData(dataDir, ["_User"]); - const rows = data["_User"] || []; - if (rows.length === 0) { - console.log(styleText("dim", " No _User data to seed")); - return { success: true }; - } - console.log(styleText("dim", ` Processing ${rows.length} _User records...`)); - - const idpSeedCode = /* js */`export async function main(input) { - const client = new tailor.idp.Client({ namespace: "my-idp" }); - const errors = []; - let processed = 0; - - for (let i = 0; i < input.users.length; i++) { - try { - await client.createUser(input.users[i]); - processed++; - console.log(\`[_User] \${i + 1}/\${input.users.length}: \${input.users[i].name}\`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(\`Row \${i} (\${input.users[i].name}): \${message}\`); - console.error(\`[_User] Row \${i} failed: \${message}\`); - } - } - - return { - success: errors.length === 0, - processed, - errors, - }; -}`; - - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: "seed-idp-user.ts", - code: idpSeedCode, - arg: { users: rows }, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - if (result.logs) { - for (const line of result.logs.split("\n").filter(Boolean)) { - console.log(styleText("dim", ` ${line}`)); - } - } - - if (result.success) { - let parsed; - try { - parsed = JSON.parse(result.result || "{}"); - } catch (e) { - console.error(styleText("red", ` ✗ Failed to parse seed result: ${e.message}`)); - return { success: false }; - } - - if (parsed.processed) { - console.log(styleText("green", ` ✓ _User: ${parsed.processed} rows processed`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - for (const err of errors) { - console.error(styleText("red", ` ✗ ${err}`)); - } - return { success: false }; - } - - return { success: true }; - } else { - console.error(styleText("red", ` ✗ Seed failed: ${result.error}`)); - return { success: false }; - } - }; - -// Main execution -try { - let allSuccess = true; - - // Determine which namespaces and types to process - const namespacesToProcess = hasNamespace - ? [values.namespace] - : Object.keys(namespaceEntities); - - for (const namespace of namespacesToProcess) { - const nsTypes = namespaceEntities[namespace] || []; - const nsDeps = namespaceDeps[namespace] || {}; - const nsSelfRefTypes = namespaceSelfRefTypes[namespace] || []; - - // Filter types if specific types requested - let typesToSeed = entitiesToProcess - ? nsTypes.filter((t) => entitiesToProcess.includes(t)) - : nsTypes; - - if (typesToSeed.length === 0) continue; - - const result = await seedViaTestExecScript(namespace, typesToSeed, nsDeps, nsSelfRefTypes); - if (!result.success) { - allSuccess = false; - } - } - - // Seed _User if included and not skipped - const shouldSeedUser = !skipIdp && (!entitiesToProcess || entitiesToProcess.includes("_User")); - if (hasIdpUser && shouldSeedUser) { - const result = await seedIdpUser(); - if (!result.success) { - allSuccess = false; - } - } - - if (allSuccess) { - console.log(styleText("green", "\n✓ Seed data generation completed successfully")); - } else { - console.error(styleText("red", "\n✗ Seed data generation completed with errors")); - process.exit(1); - } -} catch (error) { - console.error(styleText("red", `\n✗ Seed data generation failed: ${error.message}`)); - process.exit(1); -} diff --git a/example/tests/fixtures/expected/seed/exec.mjs b/example/tests/fixtures/expected/seed/exec.mjs deleted file mode 100644 index b69257e4f7..0000000000 --- a/example/tests/fixtures/expected/seed/exec.mjs +++ /dev/null @@ -1,684 +0,0 @@ -/** - * @generated - * This file is auto-generated by @tailor-platform/sdk's seedPlugin. - * Do not edit by hand: changes will be overwritten on the next `sdk generate`. - */ -import { readFileSync } from "node:fs"; -import { join, isAbsolute } from "node:path"; -import { parseArgs, styleText } from "node:util"; -import { createInterface } from "node:readline"; -import { - show, - truncate, - bundleSeedScript, - chunkSeedData, - executeScript, - initOperatorClient, - loadAccessToken, - loadWorkspaceId, -} from "@tailor-platform/sdk/cli"; - -// Handle "validate" subcommand before parseArgs -const subcommand = process.argv[2]; -if (subcommand === "validate") { - const { validateSeedData } = await import("@tailor-platform/sdk/seed"); - const validateArgs = parseArgs({ - args: process.argv.slice(3), - options: { - verbose: { type: "boolean", short: "v", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, - }); - - if (validateArgs.values.help) { - console.log(` -Usage: node exec.mjs validate [options] [path] - -Validate JSONL seed data against schema definitions. - -Arguments: - path File or directory to validate (default: ./data) - -Options: - -v, --verbose Show verbose error output - -h, --help Show help - -Examples: - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - node exec.mjs validate -v # Verbose error output - `); - process.exit(0); - } - - const configDir = import.meta.dirname; - const targetPath = validateArgs.positionals[0] || join(configDir, "data"); - const resolvedPath = isAbsolute(targetPath) ? targetPath : join(process.cwd(), targetPath); - - try { - const result = await validateSeedData({ path: resolvedPath, verbose: validateArgs.values.verbose }); - if (result.output) console.log(result.output); - if (!result.valid) { - console.error(result.error); - process.exit(1); - } - process.exit(0); - } catch (error) { - console.error(styleText("red", `Error: ${error instanceof Error ? error.message : String(error)}`)); - process.exit(1); - } -} - -// Parse command-line arguments -const { values, positionals } = parseArgs({ - options: { - "machine-user": { type: "string", short: "m" }, - namespace: { type: "string", short: "n" }, - "skip-idp": { type: "boolean", default: false }, - truncate: { type: "boolean", default: false }, - yes: { type: "boolean", default: false }, - profile: { type: "string", short: "p" }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, -}); - -if (values.help) { - console.log(` -Usage: node exec.mjs [command] [options] [types...] - -Commands: - validate [path] Validate seed data against schema (default: ./data) - -Options: - -m, --machine-user Machine user name for authentication (required if not configured) - -n, --namespace Process all types in specified namespace (excludes _User) - --skip-idp Skip IdP user (_User) entity - --truncate Truncate tables before seeding - --yes Skip confirmation prompts (for truncate) - -p, --profile Workspace profile name - -h, --help Show help - -Examples: - node exec.mjs -m admin # Process all types with machine user - node exec.mjs --namespace # Process tailordb namespace only (no _User) - node exec.mjs User Order # Process specific types only - node exec.mjs --skip-idp # Process all except _User - node exec.mjs --truncate # Truncate all tables, then seed all - node exec.mjs --truncate --yes # Truncate all tables without confirmation, then seed all - node exec.mjs --truncate --namespace # Truncate tailordb, then seed tailordb - node exec.mjs --truncate User Order # Truncate User and Order, then seed them - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - `); - process.exit(0); -} - -// Helper function to prompt for y/n confirmation -const promptConfirmation = (question) => { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(styleText("yellow", question), (answer) => { - rl.close(); - resolve(answer.toLowerCase().trim()); - }); - }); -}; - -const configDir = import.meta.dirname; -const configPath = join(configDir, "../../../tailor.config.expected.ts"); - -// Determine machine user name (CLI argument takes precedence over config default) -const defaultMachineUser = "manager-machine-user"; -const machineUserName = values["machine-user"] || defaultMachineUser; - -if (!machineUserName) { - console.error(styleText("red", "Error: Machine user name is required.")); - console.error(styleText("yellow", "Specify --machine-user or configure machineUserName in generator options.")); - process.exit(1); -} - -// Entity configuration -const namespaceEntities = { - "tailordb": [ - "Customer", - "Invoice", - "NestedProfile", - "PurchaseOrder", - "SalesOrder", - "SalesOrderCreated", - "Selfie", - "Supplier", - "User", - "UserLog", - "UserSetting", - ], - "analyticsdb": [ - "Event", - ] -}; -const namespaceDeps = { - "tailordb": { - "Customer": [], - "Invoice": ["SalesOrder"], - "NestedProfile": [], - "PurchaseOrder": ["Supplier"], - "SalesOrder": ["Customer", "User"], - "SalesOrderCreated": [], - "Selfie": [], - "Supplier": [], - "User": [], - "UserLog": ["User"], - "UserSetting": ["User"] - }, - "analyticsdb": { - "Event": [] - } -}; -const namespaceSelfRefTypes = { - "tailordb": ["Selfie"], - "analyticsdb": [] -}; -const entities = Object.values(namespaceEntities).flat(); -const hasIdpUser = true; - -// Determine which entities to process -let entitiesToProcess = null; - -const hasNamespace = !!values.namespace; -const hasTypes = positionals.length > 0; -const skipIdp = values["skip-idp"]; - -// Validate mutually exclusive options -const optionCount = [hasNamespace, hasTypes].filter(Boolean).length; -if (optionCount > 1) { - console.error(styleText("red", "Error: Options --namespace and type names are mutually exclusive.")); - process.exit(1); -} - -// --skip-idp and --namespace are redundant (namespace already excludes _User) -if (skipIdp && hasNamespace) { - console.warn(styleText("yellow", "Warning: --skip-idp is redundant with --namespace (namespace filtering already excludes _User).")); -} - -// Filter by namespace (automatically excludes _User as it has no namespace) -if (hasNamespace) { - const namespace = values.namespace; - entitiesToProcess = namespaceEntities[namespace]; - - if (!entitiesToProcess || entitiesToProcess.length === 0) { - console.error(styleText("red", `Error: No entities found in namespace "${namespace}"`)); - console.error(styleText("yellow", `Available namespaces: ${Object.keys(namespaceEntities).join(", ")}`)); - process.exit(1); - } - - console.log(styleText("cyan", `Filtering by namespace: ${namespace}`)); - console.log(styleText("dim", `Entities: ${entitiesToProcess.join(", ")}`)); -} - -// Filter by specific types -if (hasTypes) { - const requestedTypes = positionals; - const notFoundTypes = []; - const allTypes = hasIdpUser ? [...entities, "_User"] : entities; - - entitiesToProcess = requestedTypes.filter((type) => { - if (!allTypes.includes(type)) { - notFoundTypes.push(type); - return false; - } - return true; - }); - - if (notFoundTypes.length > 0) { - console.error(styleText("red", `Error: The following types were not found: ${notFoundTypes.join(", ")}`)); - console.error(styleText("yellow", `Available types: ${allTypes.join(", ")}`)); - process.exit(1); - } - - console.log(styleText("cyan", `Filtering by types: ${entitiesToProcess.join(", ")}`)); -} - -// Apply --skip-idp filter -if (skipIdp) { - if (entitiesToProcess) { - entitiesToProcess = entitiesToProcess.filter((entity) => entity !== "_User"); - } else { - entitiesToProcess = entities.filter((entity) => entity !== "_User"); - } -} - -// Get application info -const appInfo = await show({ configPath, profile: values.profile }); -const authNamespace = appInfo.auth; - -// Initialize operator client (once for all namespaces) -const accessToken = await loadAccessToken({ profile: values.profile }); -const workspaceId = await loadWorkspaceId({ profile: values.profile }); -const operatorClient = await initOperatorClient(accessToken); - - // Truncate _User via tailor.idp.Client (server-side) - const truncateIdpUser = async () => { - console.log(styleText("cyan", "Truncating _User via tailor.idp.Client...")); - - const idpTruncateCode = /* js */`export async function main() { - const client = new tailor.idp.Client({ namespace: "my-idp" }); - const errors = []; - let deleted = 0; - - // List all users with pagination - let after = undefined; - const allUsers = []; - do { - const response = await client.users(after ? { after } : undefined); - allUsers.push(...(response.users || [])); - after = response.nextPageToken; - } while (after); - - console.log(\`Found \${allUsers.length} IDP users to delete\`); - - for (const user of allUsers) { - try { - await client.deleteUser(user.id); - deleted++; - console.log(\`[_User] Deleted \${deleted}/\${allUsers.length}: \${user.name}\`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(\`User \${user.id} (\${user.name}): \${message}\`); - console.error(\`[_User] Delete failed for \${user.name}: \${message}\`); - } - } - - return { - success: errors.length === 0, - deleted, - total: allUsers.length, - errors, - }; -}`; - - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: "truncate-idp-user.ts", - code: idpTruncateCode, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - if (result.logs) { - for (const line of result.logs.split("\n").filter(Boolean)) { - console.log(styleText("dim", ` ${line}`)); - } - } - - if (result.success) { - let parsed; - try { - parsed = JSON.parse(result.result || "{}"); - } catch (e) { - console.error(styleText("red", ` ✗ Failed to parse truncation result: ${e.message}`)); - return { success: false }; - } - - if (parsed.deleted !== undefined) { - console.log(styleText("green", ` ✓ _User: ${parsed.deleted} users deleted`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - for (const err of errors) { - console.error(styleText("red", ` ✗ ${err}`)); - } - return { success: false }; - } - - return { success: true }; - } else { - console.error(styleText("red", ` ✗ Truncation failed: ${result.error}`)); - return { success: false }; - } - }; - -// Truncate tables if requested -if (values.truncate) { - const answer = values.yes ? "y" : await promptConfirmation("Are you sure you want to truncate? (y/n): "); - if (answer !== "y") { - console.log(styleText("yellow", "Truncate cancelled.")); - process.exit(0); - } - - console.log(styleText("cyan", "Truncating tables...")); - - try { - if (hasNamespace) { - await truncate({ - configPath, - profile: values.profile, - namespace: values.namespace, - }); - } else if (hasTypes) { - const typesToTruncate = entitiesToProcess.filter((t) => t !== "_User"); - if (typesToTruncate.length > 0) { - await truncate({ - configPath, - profile: values.profile, - types: typesToTruncate, - }); - } else { - console.log(styleText("dim", "No TailorDB types to truncate (only _User was specified).")); - } - } else { - await truncate({ - configPath, - profile: values.profile, - all: true, - }); - } - } catch (error) { - console.error(styleText("red", `Truncate failed: ${error.message}`)); - process.exit(1); - } - - // Truncate _User if applicable - const shouldTruncateUser = !skipIdp && !hasNamespace && (!hasTypes || entitiesToProcess.includes("_User")); - if (hasIdpUser && shouldTruncateUser) { - const truncResult = await truncateIdpUser(); - if (!truncResult.success) { - console.error(styleText("red", "IDP user truncation failed.")); - process.exit(1); - } - } - - console.log(styleText("green", "Truncate completed.")); -} - -console.log(styleText("cyan", "\nStarting seed data generation...")); -if (skipIdp) { - console.log(styleText("dim", ` Skipping IdP user (_User)`)); -} - -// Load seed data from JSONL files -const loadSeedData = (dataDir, typeNames) => { - const data = {}; - for (const typeName of typeNames) { - const jsonlPath = join(dataDir, `${typeName}.jsonl`); - try { - const content = readFileSync(jsonlPath, "utf-8").trim(); - if (content) { - data[typeName] = content.split("\n").map((line) => JSON.parse(line)); - } else { - data[typeName] = []; - } - } catch (error) { - if (error.code === "ENOENT") { - data[typeName] = []; - } else { - throw error; - } - } - } - return data; -}; - -// Topological sort for dependency order -const topologicalSort = (types, deps) => { - const visited = new Set(); - const result = []; - - const visit = (type) => { - if (visited.has(type)) return; - visited.add(type); - const typeDeps = deps[type] || []; - for (const dep of typeDeps) { - if (types.includes(dep)) { - visit(dep); - } - } - result.push(type); - }; - - for (const type of types) { - visit(type); - } - return result; -}; - -// Seed TailorDB types via testExecScript -const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) => { - const dataDir = join(configDir, "data"); - const sortedTypes = topologicalSort(typesToSeed, deps); - const data = loadSeedData(dataDir, sortedTypes); - - // Skip if no data - const typesWithData = sortedTypes.filter((t) => data[t] && data[t].length > 0); - if (typesWithData.length === 0) { - console.log(styleText("dim", ` [${namespace}] No data to seed`)); - return { success: true, processed: {} }; - } - - console.log(styleText("cyan", ` [${namespace}] Seeding ${typesWithData.length} types via Kysely batch insert...`)); - - // Bundle seed script - const bundled = await bundleSeedScript(namespace, typesWithData); - - // Chunk seed data to fit within gRPC message size limits - const chunks = chunkSeedData({ - data, - order: sortedTypes, - codeByteSize: new TextEncoder().encode(bundled.bundledCode).length, - }); - - if (chunks.length === 0) { - console.log(styleText("dim", ` [${namespace}] No data to seed`)); - return { success: true, processed: {} }; - } - - if (chunks.length > 1) { - console.log(styleText("dim", ` Split into ${chunks.length} chunks`)); - } - - const allProcessed = {}; - let hasError = false; - const allErrors = []; - - for (const chunk of chunks) { - if (chunks.length > 1) { - console.log(styleText("dim", ` Chunk ${chunk.index + 1}/${chunk.total}: ${chunk.order.join(", ")}`)); - } - - // Execute seed script for this chunk - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: `seed-${namespace}.ts`, - code: bundled.bundledCode, - arg: { data: chunk.data, order: chunk.order, selfRefTypes }, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - // Parse result and display logs - if (result.logs) { - for (const line of result.logs.split("\n").filter(Boolean)) { - console.log(styleText("dim", ` ${line}`)); - } - } - - if (result.success) { - let parsed; - try { - const parsedResult = JSON.parse(result.result || "{}"); - parsed = parsedResult && typeof parsedResult === "object" ? parsedResult : {}; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(styleText("red", ` ✗ Failed to parse seed result: ${message}`)); - hasError = true; - allErrors.push(message); - continue; - } - - const processed = parsed.processed || {}; - for (const [type, count] of Object.entries(processed)) { - allProcessed[type] = (allProcessed[type] || 0) + count; - console.log(styleText("green", ` ✓ ${type}: ${count} rows inserted`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - const errorMessage = - errors.length > 0 ? errors.join("\n ") : "Seed script reported failure"; - console.error(styleText("red", ` ✗ Seed failed:\n ${errorMessage}`)); - hasError = true; - allErrors.push(errorMessage); - } - } else { - console.error(styleText("red", ` ✗ Seed failed: ${result.error}`)); - hasError = true; - allErrors.push(result.error); - } - } - - if (hasError) { - return { success: false, error: allErrors.join("\n") }; - } - return { success: true, processed: allProcessed }; -}; - - // Seed _User via tailor.idp.Client (server-side) - const seedIdpUser = async () => { - console.log(styleText("cyan", " Seeding _User via tailor.idp.Client...")); - const dataDir = join(configDir, "data"); - const data = loadSeedData(dataDir, ["_User"]); - const rows = data["_User"] || []; - if (rows.length === 0) { - console.log(styleText("dim", " No _User data to seed")); - return { success: true }; - } - console.log(styleText("dim", ` Processing ${rows.length} _User records...`)); - - const idpSeedCode = /* js */`export async function main(input) { - const client = new tailor.idp.Client({ namespace: "my-idp" }); - const errors = []; - let processed = 0; - - for (let i = 0; i < input.users.length; i++) { - try { - await client.createUser(input.users[i]); - processed++; - console.log(\`[_User] \${i + 1}/\${input.users.length}: \${input.users[i].name}\`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(\`Row \${i} (\${input.users[i].name}): \${message}\`); - console.error(\`[_User] Row \${i} failed: \${message}\`); - } - } - - return { - success: errors.length === 0, - processed, - errors, - }; -}`; - - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: "seed-idp-user.ts", - code: idpSeedCode, - arg: { users: rows }, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - if (result.logs) { - for (const line of result.logs.split("\n").filter(Boolean)) { - console.log(styleText("dim", ` ${line}`)); - } - } - - if (result.success) { - let parsed; - try { - parsed = JSON.parse(result.result || "{}"); - } catch (e) { - console.error(styleText("red", ` ✗ Failed to parse seed result: ${e.message}`)); - return { success: false }; - } - - if (parsed.processed) { - console.log(styleText("green", ` ✓ _User: ${parsed.processed} rows processed`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - for (const err of errors) { - console.error(styleText("red", ` ✗ ${err}`)); - } - return { success: false }; - } - - return { success: true }; - } else { - console.error(styleText("red", ` ✗ Seed failed: ${result.error}`)); - return { success: false }; - } - }; - -// Main execution -try { - let allSuccess = true; - - // Determine which namespaces and types to process - const namespacesToProcess = hasNamespace - ? [values.namespace] - : Object.keys(namespaceEntities); - - for (const namespace of namespacesToProcess) { - const nsTypes = namespaceEntities[namespace] || []; - const nsDeps = namespaceDeps[namespace] || {}; - const nsSelfRefTypes = namespaceSelfRefTypes[namespace] || []; - - // Filter types if specific types requested - let typesToSeed = entitiesToProcess - ? nsTypes.filter((t) => entitiesToProcess.includes(t)) - : nsTypes; - - if (typesToSeed.length === 0) continue; - - const result = await seedViaTestExecScript(namespace, typesToSeed, nsDeps, nsSelfRefTypes); - if (!result.success) { - allSuccess = false; - } - } - - // Seed _User if included and not skipped - const shouldSeedUser = !skipIdp && (!entitiesToProcess || entitiesToProcess.includes("_User")); - if (hasIdpUser && shouldSeedUser) { - const result = await seedIdpUser(); - if (!result.success) { - allSuccess = false; - } - } - - if (allSuccess) { - console.log(styleText("green", "\n✓ Seed data generation completed successfully")); - } else { - console.error(styleText("red", "\n✗ Seed data generation completed with errors")); - process.exit(1); - } -} catch (error) { - console.error(styleText("red", `\n✗ Seed data generation failed: ${error.message}`)); - process.exit(1); -} diff --git a/packages/create-sdk/scripts/prepare-templates.js b/packages/create-sdk/scripts/prepare-templates.js index 1c5dd7574b..94914a2bdb 100644 --- a/packages/create-sdk/scripts/prepare-templates.js +++ b/packages/create-sdk/scripts/prepare-templates.js @@ -19,7 +19,32 @@ if (sdkVersionOrUrl) { console.log(`Using SDK version from package.json: ${version}`); } -// Update version in each template's package.json +// Get seed plugin version or URL the same way (falls back to its package.json) +const seedPluginVersionOrUrl = process.env.TAILOR_TEMPLATE_SEED_PLUGIN_VERSION; + +let seedPluginVersion; +if (seedPluginVersionOrUrl) { + seedPluginVersion = seedPluginVersionOrUrl; + console.log(`Using seed plugin version from environment: ${seedPluginVersion}`); +} else { + const seedPluginPackageJsonPath = resolve( + import.meta.dirname, + "..", + "..", + "sdk-plugin-seed", + "package.json", + ); + const seedPluginPackageJson = JSON.parse(readFileSync(seedPluginPackageJsonPath, "utf-8")); + seedPluginVersion = seedPluginPackageJson.version; + console.log(`Using seed plugin version from package.json: ${seedPluginVersion}`); +} + +const packageVersions = { + "@tailor-platform/sdk": version, + "@tailor-platform/sdk-plugin-seed": seedPluginVersion, +}; + +// Update versions in each template's package.json const templatesDir = resolve(import.meta.dirname, "..", "templates"); const templates = readdirSync(templatesDir, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) @@ -29,11 +54,13 @@ for (const template of templates) { if (!existsSync(packageJsonPath)) continue; const content = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - if (content.dependencies?.["@tailor-platform/sdk"]) { - content.dependencies["@tailor-platform/sdk"] = version; - } - if (content.devDependencies?.["@tailor-platform/sdk"]) { - content.devDependencies["@tailor-platform/sdk"] = version; + for (const [packageName, packageVersion] of Object.entries(packageVersions)) { + if (content.dependencies?.[packageName]) { + content.dependencies[packageName] = packageVersion; + } + if (content.devDependencies?.[packageName]) { + content.devDependencies[packageName] = packageVersion; + } } writeFileSync(packageJsonPath, JSON.stringify(content, null, 2) + "\n"); diff --git a/packages/create-sdk/templates/generators/README.md b/packages/create-sdk/templates/generators/README.md index f68f06cfcb..c3c4550e7d 100644 --- a/packages/create-sdk/templates/generators/README.md +++ b/packages/create-sdk/templates/generators/README.md @@ -7,7 +7,7 @@ Demonstrates all built-in generator plugins for the Tailor Platform SDK. - **kyselyTypePlugin** - Generates Kysely type definitions for type-safe database queries - **enumConstantsPlugin** - Generates enum constant objects from `db.enum()` fields - **fileUtilsPlugin** - Generates file upload/download utility functions -- **seedPlugin** - Generates seed data templates and execution script +- **seedPlugin** - Generates seed data templates and schemas (run them with the `tailor seed` commands from `@tailor-platform/sdk-plugin-seed`) ## Generated Files diff --git a/packages/create-sdk/templates/generators/package.json b/packages/create-sdk/templates/generators/package.json index 1c0884e0af..a89b65c19c 100644 --- a/packages/create-sdk/templates/generators/package.json +++ b/packages/create-sdk/templates/generators/package.json @@ -6,6 +6,8 @@ "scripts": { "generate": "tailor generate", "deploy": "tailor deploy", + "seed": "tailor seed apply", + "seed:validate": "tailor seed validate", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", @@ -16,6 +18,7 @@ }, "devDependencies": { "@tailor-platform/sdk": "workspace:^", + "@tailor-platform/sdk-plugin-seed": "workspace:^", "@types/node": "24.13.3", "oxfmt": "0.58.0", "oxlint": "1.73.0", diff --git a/packages/create-sdk/templates/generators/src/seed/exec.mjs b/packages/create-sdk/templates/generators/src/seed/exec.mjs deleted file mode 100644 index 0097ec9d97..0000000000 --- a/packages/create-sdk/templates/generators/src/seed/exec.mjs +++ /dev/null @@ -1,485 +0,0 @@ -/** - * @generated - * This file is auto-generated by @tailor-platform/sdk's seedPlugin. - * Do not edit by hand: changes will be overwritten on the next `sdk generate`. - */ -import { readFileSync } from "node:fs"; -import { join, isAbsolute } from "node:path"; -import { parseArgs, styleText } from "node:util"; -import { createInterface } from "node:readline"; -import { - show, - truncate, - bundleSeedScript, - chunkSeedData, - executeScript, - initOperatorClient, - loadAccessToken, - loadWorkspaceId, -} from "@tailor-platform/sdk/cli"; - -// Handle "validate" subcommand before parseArgs -const subcommand = process.argv[2]; -if (subcommand === "validate") { - const { validateSeedData } = await import("@tailor-platform/sdk/seed"); - const validateArgs = parseArgs({ - args: process.argv.slice(3), - options: { - verbose: { type: "boolean", short: "v", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, - }); - - if (validateArgs.values.help) { - console.log(` -Usage: node exec.mjs validate [options] [path] - -Validate JSONL seed data against schema definitions. - -Arguments: - path File or directory to validate (default: ./data) - -Options: - -v, --verbose Show verbose error output - -h, --help Show help - -Examples: - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - node exec.mjs validate -v # Verbose error output - `); - process.exit(0); - } - - const configDir = import.meta.dirname; - const targetPath = validateArgs.positionals[0] || join(configDir, "data"); - const resolvedPath = isAbsolute(targetPath) ? targetPath : join(process.cwd(), targetPath); - - try { - const result = await validateSeedData({ path: resolvedPath, verbose: validateArgs.values.verbose }); - if (result.output) console.log(result.output); - if (!result.valid) { - console.error(result.error); - process.exit(1); - } - process.exit(0); - } catch (error) { - console.error(styleText("red", `Error: ${error instanceof Error ? error.message : String(error)}`)); - process.exit(1); - } -} - -// Parse command-line arguments -const { values, positionals } = parseArgs({ - options: { - "machine-user": { type: "string", short: "m" }, - namespace: { type: "string", short: "n" }, - "skip-idp": { type: "boolean", default: false }, - truncate: { type: "boolean", default: false }, - yes: { type: "boolean", default: false }, - profile: { type: "string", short: "p" }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, -}); - -if (values.help) { - console.log(` -Usage: node exec.mjs [command] [options] [types...] - -Commands: - validate [path] Validate seed data against schema (default: ./data) - -Options: - -m, --machine-user Machine user name for authentication (required if not configured) - -n, --namespace Process all types in specified namespace (excludes _User) - --skip-idp Skip IdP user (_User) entity - --truncate Truncate tables before seeding - --yes Skip confirmation prompts (for truncate) - -p, --profile Workspace profile name - -h, --help Show help - -Examples: - node exec.mjs -m admin # Process all types with machine user - node exec.mjs --namespace # Process tailordb namespace only (no _User) - node exec.mjs User Order # Process specific types only - node exec.mjs --skip-idp # Process all except _User - node exec.mjs --truncate # Truncate all tables, then seed all - node exec.mjs --truncate --yes # Truncate all tables without confirmation, then seed all - node exec.mjs --truncate --namespace # Truncate tailordb, then seed tailordb - node exec.mjs --truncate User Order # Truncate User and Order, then seed them - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - `); - process.exit(0); -} - -// Helper function to prompt for y/n confirmation -const promptConfirmation = (question) => { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(styleText("yellow", question), (answer) => { - rl.close(); - resolve(answer.toLowerCase().trim()); - }); - }); -}; - -const configDir = import.meta.dirname; -const configPath = join(configDir, "../../tailor.config.ts"); - -// Determine machine user name (CLI argument takes precedence over config default) -const defaultMachineUser = "admin"; -const machineUserName = values["machine-user"] || defaultMachineUser; - -if (!machineUserName) { - console.error(styleText("red", "Error: Machine user name is required.")); - console.error(styleText("yellow", "Specify --machine-user or configure machineUserName in generator options.")); - process.exit(1); -} - -// Entity configuration -const namespaceEntities = { - "main-db": [ - "Category", - "Order", - "Product", - "User", - ] -}; -const namespaceDeps = { - "main-db": { - "Category": [], - "Order": ["Product", "User"], - "Product": ["Category"], - "User": [] - } -}; -const namespaceSelfRefTypes = { - "main-db": ["Category"] -}; -const entities = Object.values(namespaceEntities).flat(); -const hasIdpUser = false; - -// Determine which entities to process -let entitiesToProcess = null; - -const hasNamespace = !!values.namespace; -const hasTypes = positionals.length > 0; -const skipIdp = values["skip-idp"]; - -// Validate mutually exclusive options -const optionCount = [hasNamespace, hasTypes].filter(Boolean).length; -if (optionCount > 1) { - console.error(styleText("red", "Error: Options --namespace and type names are mutually exclusive.")); - process.exit(1); -} - -// --skip-idp and --namespace are redundant (namespace already excludes _User) -if (skipIdp && hasNamespace) { - console.warn(styleText("yellow", "Warning: --skip-idp is redundant with --namespace (namespace filtering already excludes _User).")); -} - -// Filter by namespace (automatically excludes _User as it has no namespace) -if (hasNamespace) { - const namespace = values.namespace; - entitiesToProcess = namespaceEntities[namespace]; - - if (!entitiesToProcess || entitiesToProcess.length === 0) { - console.error(styleText("red", `Error: No entities found in namespace "${namespace}"`)); - console.error(styleText("yellow", `Available namespaces: ${Object.keys(namespaceEntities).join(", ")}`)); - process.exit(1); - } - - console.log(styleText("cyan", `Filtering by namespace: ${namespace}`)); - console.log(styleText("dim", `Entities: ${entitiesToProcess.join(", ")}`)); -} - -// Filter by specific types -if (hasTypes) { - const requestedTypes = positionals; - const notFoundTypes = []; - const allTypes = hasIdpUser ? [...entities, "_User"] : entities; - - entitiesToProcess = requestedTypes.filter((type) => { - if (!allTypes.includes(type)) { - notFoundTypes.push(type); - return false; - } - return true; - }); - - if (notFoundTypes.length > 0) { - console.error(styleText("red", `Error: The following types were not found: ${notFoundTypes.join(", ")}`)); - console.error(styleText("yellow", `Available types: ${allTypes.join(", ")}`)); - process.exit(1); - } - - console.log(styleText("cyan", `Filtering by types: ${entitiesToProcess.join(", ")}`)); -} - -// Apply --skip-idp filter -if (skipIdp) { - if (entitiesToProcess) { - entitiesToProcess = entitiesToProcess.filter((entity) => entity !== "_User"); - } else { - entitiesToProcess = entities.filter((entity) => entity !== "_User"); - } -} - -// Get application info -const appInfo = await show({ configPath, profile: values.profile }); -const authNamespace = appInfo.auth; - -// Initialize operator client (once for all namespaces) -const accessToken = await loadAccessToken({ profile: values.profile }); -const workspaceId = await loadWorkspaceId({ profile: values.profile }); -const operatorClient = await initOperatorClient(accessToken); - - - -// Truncate tables if requested -if (values.truncate) { - const answer = values.yes ? "y" : await promptConfirmation("Are you sure you want to truncate? (y/n): "); - if (answer !== "y") { - console.log(styleText("yellow", "Truncate cancelled.")); - process.exit(0); - } - - console.log(styleText("cyan", "Truncating tables...")); - - try { - if (hasNamespace) { - await truncate({ - configPath, - profile: values.profile, - namespace: values.namespace, - }); - } else if (hasTypes) { - const typesToTruncate = entitiesToProcess.filter((t) => t !== "_User"); - if (typesToTruncate.length > 0) { - await truncate({ - configPath, - profile: values.profile, - types: typesToTruncate, - }); - } else { - console.log(styleText("dim", "No TailorDB types to truncate (only _User was specified).")); - } - } else { - await truncate({ - configPath, - profile: values.profile, - all: true, - }); - } - } catch (error) { - console.error(styleText("red", `Truncate failed: ${error.message}`)); - process.exit(1); - } - - - - console.log(styleText("green", "Truncate completed.")); -} - -console.log(styleText("cyan", "\nStarting seed data generation...")); -if (skipIdp) { - console.log(styleText("dim", ` Skipping IdP user (_User)`)); -} - -// Load seed data from JSONL files -const loadSeedData = (dataDir, typeNames) => { - const data = {}; - for (const typeName of typeNames) { - const jsonlPath = join(dataDir, `${typeName}.jsonl`); - try { - const content = readFileSync(jsonlPath, "utf-8").trim(); - if (content) { - data[typeName] = content.split("\n").map((line) => JSON.parse(line)); - } else { - data[typeName] = []; - } - } catch (error) { - if (error.code === "ENOENT") { - data[typeName] = []; - } else { - throw error; - } - } - } - return data; -}; - -// Topological sort for dependency order -const topologicalSort = (types, deps) => { - const visited = new Set(); - const result = []; - - const visit = (type) => { - if (visited.has(type)) return; - visited.add(type); - const typeDeps = deps[type] || []; - for (const dep of typeDeps) { - if (types.includes(dep)) { - visit(dep); - } - } - result.push(type); - }; - - for (const type of types) { - visit(type); - } - return result; -}; - -// Seed TailorDB types via testExecScript -const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) => { - const dataDir = join(configDir, "data"); - const sortedTypes = topologicalSort(typesToSeed, deps); - const data = loadSeedData(dataDir, sortedTypes); - - // Skip if no data - const typesWithData = sortedTypes.filter((t) => data[t] && data[t].length > 0); - if (typesWithData.length === 0) { - console.log(styleText("dim", ` [${namespace}] No data to seed`)); - return { success: true, processed: {} }; - } - - console.log(styleText("cyan", ` [${namespace}] Seeding ${typesWithData.length} types via Kysely batch insert...`)); - - // Bundle seed script - const bundled = await bundleSeedScript(namespace, typesWithData); - - // Chunk seed data to fit within gRPC message size limits - const chunks = chunkSeedData({ - data, - order: sortedTypes, - codeByteSize: new TextEncoder().encode(bundled.bundledCode).length, - }); - - if (chunks.length === 0) { - console.log(styleText("dim", ` [${namespace}] No data to seed`)); - return { success: true, processed: {} }; - } - - if (chunks.length > 1) { - console.log(styleText("dim", ` Split into ${chunks.length} chunks`)); - } - - const allProcessed = {}; - let hasError = false; - const allErrors = []; - - for (const chunk of chunks) { - if (chunks.length > 1) { - console.log(styleText("dim", ` Chunk ${chunk.index + 1}/${chunk.total}: ${chunk.order.join(", ")}`)); - } - - // Execute seed script for this chunk - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: `seed-${namespace}.ts`, - code: bundled.bundledCode, - arg: { data: chunk.data, order: chunk.order, selfRefTypes }, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - // Parse result and display logs - if (result.logs) { - for (const line of result.logs.split("\n").filter(Boolean)) { - console.log(styleText("dim", ` ${line}`)); - } - } - - if (result.success) { - let parsed; - try { - const parsedResult = JSON.parse(result.result || "{}"); - parsed = parsedResult && typeof parsedResult === "object" ? parsedResult : {}; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(styleText("red", ` ✗ Failed to parse seed result: ${message}`)); - hasError = true; - allErrors.push(message); - continue; - } - - const processed = parsed.processed || {}; - for (const [type, count] of Object.entries(processed)) { - allProcessed[type] = (allProcessed[type] || 0) + count; - console.log(styleText("green", ` ✓ ${type}: ${count} rows inserted`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - const errorMessage = - errors.length > 0 ? errors.join("\n ") : "Seed script reported failure"; - console.error(styleText("red", ` ✗ Seed failed:\n ${errorMessage}`)); - hasError = true; - allErrors.push(errorMessage); - } - } else { - console.error(styleText("red", ` ✗ Seed failed: ${result.error}`)); - hasError = true; - allErrors.push(result.error); - } - } - - if (hasError) { - return { success: false, error: allErrors.join("\n") }; - } - return { success: true, processed: allProcessed }; -}; - - - -// Main execution -try { - let allSuccess = true; - - // Determine which namespaces and types to process - const namespacesToProcess = hasNamespace - ? [values.namespace] - : Object.keys(namespaceEntities); - - for (const namespace of namespacesToProcess) { - const nsTypes = namespaceEntities[namespace] || []; - const nsDeps = namespaceDeps[namespace] || {}; - const nsSelfRefTypes = namespaceSelfRefTypes[namespace] || []; - - // Filter types if specific types requested - let typesToSeed = entitiesToProcess - ? nsTypes.filter((t) => entitiesToProcess.includes(t)) - : nsTypes; - - if (typesToSeed.length === 0) continue; - - const result = await seedViaTestExecScript(namespace, typesToSeed, nsDeps, nsSelfRefTypes); - if (!result.success) { - allSuccess = false; - } - } - - - - if (allSuccess) { - console.log(styleText("green", "\n✓ Seed data generation completed successfully")); - } else { - console.error(styleText("red", "\n✗ Seed data generation completed with errors")); - process.exit(1); - } -} catch (error) { - console.error(styleText("red", `\n✗ Seed data generation failed: ${error.message}`)); - process.exit(1); -} diff --git a/packages/sdk-codemod/src/registry.ts b/packages/sdk-codemod/src/registry.ts index 8083aeb431..4f9fe2e70e 100644 --- a/packages/sdk-codemod/src/registry.ts +++ b/packages/sdk-codemod/src/registry.ts @@ -1201,6 +1201,39 @@ export const allCodemods: CodemodPackage[] = [ "single generation pass and resolves once it completes.", ].join("\n"), }, + { + id: "v2/seed-exec-to-cli-plugin", + name: "Generated seed exec.mjs → tailor seed CLI plugin", + description: + "`seedPlugin` no longer generates the `exec.mjs` seed runner. Seeding and validation move to the `tailor seed` commands provided by the `@tailor-platform/sdk-plugin-seed` CLI plugin: install it as a devDependency, replace `node /exec.mjs` invocations with `tailor seed apply` and `node /exec.mjs validate` with `tailor seed validate`, and delete the stale generated `/exec.mjs` file. Seed data and schema generation (`data/*.jsonl`, `data/*.schema.ts`) is unchanged, and the `tailor seed apply` options mirror the old script (`--machine-user`, `--namespace`, `--skip-idp`, `--truncate`, `--yes`, type-name arguments).", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_PENDING, + // No scriptPath: this is a codemod-less ("manual") migration. + filePatterns: ["**/package.json", "**/*.{sh,yml,yaml,md,mjs,ts}"], + suspiciousPatterns: ["exec.mjs"], + examples: [ + { + before: '"seed": "node ./seed/exec.mjs",\n"seed:validate": "node ./seed/exec.mjs validate"', + after: '"seed": "tailor seed apply",\n"seed:validate": "tailor seed validate"', + lang: "jsonc", + }, + ], + prompt: [ + "seedPlugin no longer generates the exec.mjs seed runner in v2. The tailor seed", + "CLI plugin (@tailor-platform/sdk-plugin-seed) replaces it:", + "", + "- Install @tailor-platform/sdk-plugin-seed as a devDependency next to", + " @tailor-platform/sdk.", + "- Replace `node /exec.mjs [options] [types...]` invocations with", + " `tailor seed apply [options] [types...]` (same options: --machine-user/-m,", + " --namespace/-n, --skip-idp, --truncate, --yes, and type-name arguments).", + "- Replace `node /exec.mjs validate [path]` with", + " `tailor seed validate [path]`.", + "- Delete the stale generated `/exec.mjs` file; keep the data/", + " directory (JSONL data and generated schemas) as-is.", + ].join("\n"), + }, { id: "v2/node-minimum-22-15-0", name: "Node.js minimum version raised to 22.15.0", diff --git a/packages/sdk-plugin-seed/.oxlintrc.json b/packages/sdk-plugin-seed/.oxlintrc.json new file mode 100644 index 0000000000..748e9dabef --- /dev/null +++ b/packages/sdk-plugin-seed/.oxlintrc.json @@ -0,0 +1,64 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn"], + "categories": { + "correctness": "error" + }, + "env": { + "builtin": true + }, + "ignorePatterns": ["dist/"], + "rules": { + "unicorn/no-array-reverse": "error", + "unicorn/no-array-sort": "error", + "getter-return": "error", + "no-unreachable": "error", + "no-array-constructor": "error", + "no-case-declarations": "error", + "no-fallthrough": "error", + "no-prototype-builtins": "error", + "no-redeclare": "error", + "no-empty": "error", + "no-regex-spaces": "error", + "no-unexpected-multiline": "error", + "preserve-caught-error": "error", + "typescript/ban-ts-comment": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-explicit-any": "error", + "typescript/no-namespace": "error", + "typescript/no-require-imports": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-function-type": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + "rules": { + "constructor-super": "off", + "getter-return": "off", + "no-class-assign": "off", + "no-const-assign": "off", + "no-dupe-class-members": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-new-native-nonconstructor": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-unreachable": "off", + "no-unsafe-negation": "off", + "no-var": "error", + "no-with": "off", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error" + } + } + ], + "jsPlugins": ["eslint-plugin-zod", "../../scripts/oxlint/tailor-zod-plugin.cjs"] +} diff --git a/packages/sdk-plugin-seed/README.md b/packages/sdk-plugin-seed/README.md new file mode 100644 index 0000000000..ccb91db080 --- /dev/null +++ b/packages/sdk-plugin-seed/README.md @@ -0,0 +1,49 @@ +# @tailor-platform/sdk-plugin-seed + +Tailor CLI plugin that provides the `tailor seed` commands: seed TailorDB (and IdP `_User`) data from JSONL files generated by `seedPlugin`, and validate that data against the generated schemas. + +> [!NOTE] +> This package is a **CLI plugin**: it ships an external `tailor-seed` executable that the Tailor CLI dispatches to when you run `tailor seed`. Keep `seedPlugin` from `@tailor-platform/sdk/plugin/seed` in `definePlugins()` — it generates the seed data and schema files this plugin consumes. + +## Installation + +Install it next to `@tailor-platform/sdk` in your project: + +```bash +npm install -D @tailor-platform/sdk-plugin-seed@next +``` + +The Tailor CLI discovers the plugin automatically from `node_modules/.bin` (or your `PATH`). Run `tailor plugin list` to confirm it resolves. + +## Usage + +```bash +# Seed everything (TailorDB types, then IdP _User) +tailor seed apply + +# Truncate target tables first, without a confirmation prompt +tailor seed apply --truncate --yes + +# Seed a single TailorDB namespace (excludes _User) +tailor seed apply --namespace my-db + +# Seed specific types only +tailor seed apply User Order + +# Validate JSONL seed data against the generated schemas +tailor seed validate +tailor seed validate ./seed/data/User.jsonl +``` + +The machine user used for seeding comes from `--machine-user` or the `machineUserName` seedPlugin option: + +```ts +definePlugins( + seedPlugin({ + distPath: "./seed", + machineUserName: "admin-machine-user", + }), +); +``` + +Run `tailor seed --help` for the full option reference. diff --git a/packages/sdk-plugin-seed/package.json b/packages/sdk-plugin-seed/package.json new file mode 100644 index 0000000000..52198b8e52 --- /dev/null +++ b/packages/sdk-plugin-seed/package.json @@ -0,0 +1,48 @@ +{ + "name": "@tailor-platform/sdk-plugin-seed", + "version": "0.1.0-next.0", + "description": "Tailor CLI plugin providing the `tailor seed` commands (TailorDB and IdP seed data)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/tailor-platform/sdk.git", + "directory": "packages/sdk-plugin-seed" + }, + "bin": { + "tailor-seed": "./dist/index.js" + }, + "files": [ + "CHANGELOG.md", + "dist", + "README.md" + ], + "type": "module", + "scripts": { + "build": "tsdown", + "lint": "oxlint .", + "lint:fix": "oxlint . --fix", + "typecheck": "tsc --noEmit", + "test": "vitest", + "prepack": "pnpm run build", + "publint": "publint --strict" + }, + "dependencies": { + "chalk": "5.6.2", + "pathe": "2.0.3", + "pkg-types": "2.3.1", + "politty": "0.11.2", + "zod": "4.4.3" + }, + "devDependencies": { + "@tailor-platform/sdk": "workspace:^", + "@types/node": "24.13.3", + "eslint-plugin-zod": "4.7.0", + "oxlint": "1.73.0", + "tsdown": "0.22.5", + "typescript": "6.0.3", + "vitest": "4.1.10" + }, + "peerDependencies": { + "@tailor-platform/sdk": "workspace:^" + } +} diff --git a/packages/sdk-plugin-seed/src/apply.ts b/packages/sdk-plugin-seed/src/apply.ts new file mode 100644 index 0000000000..dab740ed54 --- /dev/null +++ b/packages/sdk-plugin-seed/src/apply.ts @@ -0,0 +1,410 @@ +import { createInterface } from "node:readline"; +import { + bundleSeedScript, + chunkSeedData, + executeScript, + initOperatorClient, + loadAccessToken, + loadSeedContext, + loadWorkspaceId, + show, + truncate, +} from "@tailor-platform/sdk/cli"; +import chalk from "chalk"; +import * as path from "pathe"; +import { arg } from "politty"; +import { z } from "zod"; +import { selectEntities } from "./entities"; +import { loadSeedData } from "./jsonl"; +import { deploymentArgs } from "./shared/args"; +import { defineAppCommand } from "./shared/command"; +import { logger } from "./shared/logger"; +import { topologicalSort } from "./topo-sort"; +import type { OperatorClient, ScriptExecutionResult } from "@tailor-platform/sdk/cli"; + +interface InvokerContext { + operatorClient: OperatorClient; + workspaceId: string; + authNamespace: string; + machineUserName: string; +} + +interface SeedNamespaceParams { + invoker: InvokerContext; + namespace: string; + typesToSeed: string[]; + dependencies: Record; + selfRefTypes: string[]; + dataDir: string; +} + +interface IdpScriptParams { + invoker: InvokerContext; + scriptCode: string; +} + +function promptConfirmation(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(chalk.yellow(question), (answer) => { + rl.close(); + resolve(answer.toLowerCase().trim() === "y"); + }); + }); +} + +function logExecutionLogs(logs: string | undefined, indent: string): void { + if (!logs) return; + for (const line of logs.split("\n").filter(Boolean)) { + logger.log(chalk.dim(`${indent}${line}`)); + } +} + +function parseExecutionResult( + result: ScriptExecutionResult, + indent: string, +): { + success: boolean; + parsed: Record; + errors: string[]; +} { + logExecutionLogs(result.logs, indent); + + if (!result.success) { + return { success: false, parsed: {}, errors: [result.error ?? "Script execution failed"] }; + } + + let parsed: Record; + try { + const value: unknown = JSON.parse(result.result || "{}"); + parsed = value && typeof value === "object" ? (value as Record) : {}; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, parsed: {}, errors: [`Failed to parse result: ${message}`] }; + } + + if (parsed.success === false) { + const errors = Array.isArray(parsed.errors) ? (parsed.errors as string[]) : []; + return { + success: false, + parsed, + errors: errors.length > 0 ? errors : ["Script reported failure"], + }; + } + + return { success: true, parsed, errors: [] }; +} + +async function seedNamespace(params: SeedNamespaceParams): Promise { + const { invoker, namespace, typesToSeed, dependencies, selfRefTypes, dataDir } = params; + const sortedTypes = topologicalSort(typesToSeed, dependencies); + const data = loadSeedData(dataDir, sortedTypes); + + const typesWithData = sortedTypes.filter((type) => data[type] && data[type].length > 0); + if (typesWithData.length === 0) { + logger.log(chalk.dim(` [${namespace}] No data to seed`)); + return true; + } + + logger.info(` [${namespace}] Seeding ${typesWithData.length} types via Kysely batch insert...`, { + mode: "plain", + }); + + const bundled = await bundleSeedScript(namespace, typesWithData); + const chunks = chunkSeedData({ + data, + order: sortedTypes, + codeByteSize: new TextEncoder().encode(bundled.bundledCode).length, + }); + + if (chunks.length === 0) { + logger.log(chalk.dim(` [${namespace}] No data to seed`)); + return true; + } + if (chunks.length > 1) { + logger.log(chalk.dim(` Split into ${chunks.length} chunks`)); + } + + let success = true; + for (const chunk of chunks) { + if (chunks.length > 1) { + logger.log( + chalk.dim(` Chunk ${chunk.index + 1}/${chunk.total}: ${chunk.order.join(", ")}`), + ); + } + + const result = await executeScript({ + client: invoker.operatorClient, + workspaceId: invoker.workspaceId, + name: `seed-${namespace}.ts`, + code: bundled.bundledCode, + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, + invoker: { + namespace: invoker.authNamespace, + machineUserName: invoker.machineUserName, + }, + }); + + const { success: chunkSuccess, parsed, errors } = parseExecutionResult(result, " "); + const processed = (parsed.processed ?? {}) as Record; + for (const [type, count] of Object.entries(processed)) { + logger.log(chalk.green(` ✓ ${type}: ${count} rows inserted`)); + } + if (!chunkSuccess) { + logger.error(` Seed failed:\n ${errors.join("\n ")}`, { mode: "plain" }); + success = false; + } + } + return success; +} + +async function seedIdpUser(params: IdpScriptParams & { dataDir: string }): Promise { + const { invoker, scriptCode, dataDir } = params; + logger.info(" Seeding _User via tailor.idp.Client...", { mode: "plain" }); + + const rows = loadSeedData(dataDir, ["_User"])._User ?? []; + if (rows.length === 0) { + logger.log(chalk.dim(" No _User data to seed")); + return true; + } + logger.log(chalk.dim(` Processing ${rows.length} _User records...`)); + + const result = await executeScript({ + client: invoker.operatorClient, + workspaceId: invoker.workspaceId, + name: "seed-idp-user.ts", + code: scriptCode, + arg: { users: rows }, + invoker: { + namespace: invoker.authNamespace, + machineUserName: invoker.machineUserName, + }, + }); + + const { success, parsed, errors } = parseExecutionResult(result, " "); + if (typeof parsed.processed === "number") { + logger.log(chalk.green(` ✓ _User: ${parsed.processed} rows processed`)); + } + if (!success) { + for (const error of errors) { + logger.error(` ${error}`, { mode: "plain" }); + } + } + return success; +} + +async function truncateIdpUser(params: IdpScriptParams): Promise { + const { invoker, scriptCode } = params; + logger.info("Truncating _User via tailor.idp.Client...", { mode: "plain" }); + + const result = await executeScript({ + client: invoker.operatorClient, + workspaceId: invoker.workspaceId, + name: "truncate-idp-user.ts", + code: scriptCode, + invoker: { + namespace: invoker.authNamespace, + machineUserName: invoker.machineUserName, + }, + }); + + const { success, parsed, errors } = parseExecutionResult(result, " "); + if (typeof parsed.deleted === "number") { + logger.log(chalk.green(` ✓ _User: ${parsed.deleted} users deleted`)); + } + if (!success) { + for (const error of errors) { + logger.error(` ${error}`, { mode: "plain" }); + } + } + return success; +} + +export const seedApplyCommand = defineAppCommand({ + name: "apply", + description: "Seed TailorDB (and IdP `_User`) data from generated JSONL files.", + args: z.strictObject({ + ...deploymentArgs, + "machine-user": arg(z.string().optional(), { + alias: "m", + description: + "Machine user name for authentication (required unless machineUserName is configured in seedPlugin options)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Seed all types in the specified TailorDB namespace (excludes _User)", + }), + "skip-idp": arg(z.boolean().default(false), { + description: "Skip the IdP user (_User) entity", + }), + truncate: arg(z.boolean().default(false), { + description: "Truncate target tables before seeding", + }), + yes: arg(z.boolean().default(false), { + alias: "y", + description: "Skip confirmation prompts (for --truncate)", + }), + types: arg(z.array(z.string()).default([]), { + positional: true, + description: "Type names to seed (default: all types)", + }), + }), + run: async (args) => { + const context = await loadSeedContext({ configPath: args.config }); + + const machineUserName = args["machine-user"] ?? context.machineUserName; + if (!machineUserName) { + throw new Error( + "Machine user name is required. " + + "Specify --machine-user or configure machineUserName in seedPlugin options.", + ); + } + + const namespaceEntities = Object.fromEntries( + context.namespaces.map((ns) => [ns.namespace, ns.types]), + ); + const hasIdpUser = context.idpUser !== null; + const selection = selectEntities({ + namespaceEntities, + hasIdpUser, + namespace: args.namespace, + types: args.types, + skipIdp: args["skip-idp"], + }); + for (const warning of selection.warnings) { + logger.warn(warning); + } + if (args.namespace) { + logger.info(`Filtering by namespace: ${args.namespace}`); + logger.log(chalk.dim(`Entities: ${(selection.entitiesToProcess ?? []).join(", ")}`)); + } else if (args.types.length > 0) { + logger.info(`Filtering by types: ${(selection.entitiesToProcess ?? []).join(", ")}`); + } + + const appInfo = await show({ + configPath: args.config, + profile: args.profile, + workspaceId: args["workspace-id"], + }); + const invoker: InvokerContext = { + operatorClient: await initOperatorClient(await loadAccessToken({ profile: args.profile })), + workspaceId: await loadWorkspaceId({ + workspaceId: args["workspace-id"], + profile: args.profile, + }), + authNamespace: appInfo.auth, + machineUserName, + }; + const dataDir = path.join(context.distPath, "data"); + + if (args.truncate) { + const confirmed = + args.yes || (await promptConfirmation("Are you sure you want to truncate? (y/n): ")); + if (!confirmed) { + logger.warn("Truncate cancelled."); + return; + } + + logger.info("Truncating tables..."); + if (args.namespace) { + await truncate({ + configPath: args.config, + profile: args.profile, + workspaceId: args["workspace-id"], + namespace: args.namespace, + }); + } else if (args.types.length > 0) { + const typesToTruncate = (selection.entitiesToProcess ?? []).filter( + (type) => type !== "_User", + ); + if (typesToTruncate.length > 0) { + await truncate({ + configPath: args.config, + profile: args.profile, + workspaceId: args["workspace-id"], + types: typesToTruncate, + }); + } else { + logger.log(chalk.dim("No TailorDB types to truncate (only _User was specified).")); + } + } else { + await truncate({ + configPath: args.config, + profile: args.profile, + workspaceId: args["workspace-id"], + all: true, + }); + } + + const shouldTruncateUser = + hasIdpUser && + !args["skip-idp"] && + !args.namespace && + (args.types.length === 0 || (selection.entitiesToProcess ?? []).includes("_User")); + if (shouldTruncateUser && context.idpUser) { + const truncated = await truncateIdpUser({ + invoker, + scriptCode: context.idpUser.truncateScriptCode, + }); + if (!truncated) { + throw new Error("IdP user truncation failed."); + } + } + logger.success("Truncate completed."); + } + + logger.newline(); + logger.info("Starting seed data generation..."); + if (args["skip-idp"]) { + logger.log(chalk.dim(" Skipping IdP user (_User)")); + } + + let allSuccess = true; + + const namespacesToProcess = args.namespace + ? [args.namespace] + : context.namespaces.map((ns) => ns.namespace); + for (const namespace of namespacesToProcess) { + const nsConfig = context.namespaces.find((ns) => ns.namespace === namespace); + if (!nsConfig) continue; + + const typesToSeed = selection.entitiesToProcess + ? nsConfig.types.filter((type) => selection.entitiesToProcess?.includes(type)) + : nsConfig.types; + if (typesToSeed.length === 0) continue; + + const seeded = await seedNamespace({ + invoker, + namespace, + typesToSeed, + dependencies: nsConfig.dependencies, + selfRefTypes: nsConfig.selfRefTypes, + dataDir, + }); + if (!seeded) { + allSuccess = false; + } + } + + const shouldSeedUser = + hasIdpUser && + !args["skip-idp"] && + (!selection.entitiesToProcess || selection.entitiesToProcess.includes("_User")); + if (shouldSeedUser && context.idpUser) { + const seeded = await seedIdpUser({ + invoker, + scriptCode: context.idpUser.seedScriptCode, + dataDir, + }); + if (!seeded) { + allSuccess = false; + } + } + + logger.newline(); + if (!allSuccess) { + throw new Error("Seed data generation completed with errors"); + } + logger.success("Seed data generation completed successfully"); + }, +}); diff --git a/packages/sdk-plugin-seed/src/entities.test.ts b/packages/sdk-plugin-seed/src/entities.test.ts new file mode 100644 index 0000000000..d1ba062109 --- /dev/null +++ b/packages/sdk-plugin-seed/src/entities.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; +import { selectEntities } from "./entities"; + +const namespaceEntities = { + "main-db": ["User", "Order"], + "sub-db": ["Event"], +}; + +describe("selectEntities", () => { + test("processes everything when no filter is given", () => { + const selection = selectEntities({ + namespaceEntities, + hasIdpUser: true, + types: [], + skipIdp: false, + }); + expect(selection.entitiesToProcess).toBeNull(); + expect(selection.warnings).toEqual([]); + }); + + test("rejects combining --namespace with type names", () => { + expect(() => + selectEntities({ + namespaceEntities, + hasIdpUser: false, + namespace: "main-db", + types: ["User"], + skipIdp: false, + }), + ).toThrow(/mutually exclusive/); + }); + + test("selects all types of the given namespace", () => { + const selection = selectEntities({ + namespaceEntities, + hasIdpUser: true, + namespace: "main-db", + types: [], + skipIdp: false, + }); + expect(selection.entitiesToProcess).toEqual(["User", "Order"]); + }); + + test("rejects an unknown namespace with available names", () => { + expect(() => + selectEntities({ + namespaceEntities, + hasIdpUser: false, + namespace: "nope", + types: [], + skipIdp: false, + }), + ).toThrow(/Available namespaces: main-db, sub-db/); + }); + + test("accepts _User as a type only when the config has an IdP user", () => { + const selection = selectEntities({ + namespaceEntities, + hasIdpUser: true, + types: ["_User", "User"], + skipIdp: false, + }); + expect(selection.entitiesToProcess).toEqual(["_User", "User"]); + + expect(() => + selectEntities({ + namespaceEntities, + hasIdpUser: false, + types: ["_User"], + skipIdp: false, + }), + ).toThrow(/types were not found: _User/); + }); + + test("--skip-idp removes _User from the selection", () => { + const explicit = selectEntities({ + namespaceEntities, + hasIdpUser: true, + types: ["_User", "User"], + skipIdp: true, + }); + expect(explicit.entitiesToProcess).toEqual(["User"]); + + const all = selectEntities({ + namespaceEntities, + hasIdpUser: true, + types: [], + skipIdp: true, + }); + expect(all.entitiesToProcess).toEqual(["User", "Order", "Event"]); + }); + + test("warns that --skip-idp is redundant with --namespace", () => { + const selection = selectEntities({ + namespaceEntities, + hasIdpUser: true, + namespace: "main-db", + types: [], + skipIdp: true, + }); + expect(selection.warnings).toEqual([ + expect.stringContaining("--skip-idp is redundant with --namespace"), + ]); + expect(selection.entitiesToProcess).toEqual(["User", "Order"]); + }); +}); diff --git a/packages/sdk-plugin-seed/src/entities.ts b/packages/sdk-plugin-seed/src/entities.ts new file mode 100644 index 0000000000..0bd7afa1c7 --- /dev/null +++ b/packages/sdk-plugin-seed/src/entities.ts @@ -0,0 +1,72 @@ +export interface SelectEntitiesOptions { + /** Type names per TailorDB namespace. */ + namespaceEntities: Record; + /** Whether the config seeds IdP `_User` records. */ + hasIdpUser: boolean; + /** Namespace filter (mutually exclusive with `types`). */ + namespace?: string | undefined; + /** Explicit type names to process (mutually exclusive with `namespace`). */ + types: string[]; + /** Whether to exclude the IdP `_User` entity. */ + skipIdp: boolean; +} + +export interface EntitySelection { + /** Types to process, or null when everything is processed. */ + entitiesToProcess: string[] | null; + /** Non-fatal notes about the selection (e.g. redundant flags). */ + warnings: string[]; +} + +/** + * Resolve which entities a seed run should process from the namespace/type + * filters, mirroring the selection rules of the generated seed script. + * @param options - Selection filters and available entities + * @returns The resolved selection and any warnings + */ +export function selectEntities(options: SelectEntitiesOptions): EntitySelection { + const { namespaceEntities, hasIdpUser, namespace, types, skipIdp } = options; + const entities = Object.values(namespaceEntities).flat(); + const warnings: string[] = []; + + if (namespace && types.length > 0) { + throw new Error("Options --namespace and type names are mutually exclusive."); + } + + if (skipIdp && namespace) { + warnings.push( + "--skip-idp is redundant with --namespace (namespace filtering already excludes _User).", + ); + } + + let entitiesToProcess: string[] | null = null; + + if (namespace) { + const namespaceTypes = namespaceEntities[namespace]; + if (!namespaceTypes || namespaceTypes.length === 0) { + const available = Object.keys(namespaceEntities).join(", "); + throw new Error( + `No entities found in namespace "${namespace}". Available namespaces: ${available}`, + ); + } + entitiesToProcess = namespaceTypes; + } + + if (types.length > 0) { + const allTypes = hasIdpUser ? [...entities, "_User"] : entities; + const notFoundTypes = types.filter((type) => !allTypes.includes(type)); + if (notFoundTypes.length > 0) { + throw new Error( + `The following types were not found: ${notFoundTypes.join(", ")}. ` + + `Available types: ${allTypes.join(", ")}`, + ); + } + entitiesToProcess = types; + } + + if (skipIdp) { + entitiesToProcess = (entitiesToProcess ?? entities).filter((entity) => entity !== "_User"); + } + + return { entitiesToProcess, warnings }; +} diff --git a/packages/sdk-plugin-seed/src/index.ts b/packages/sdk-plugin-seed/src/index.ts new file mode 100644 index 0000000000..acf87be3a3 --- /dev/null +++ b/packages/sdk-plugin-seed/src/index.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import * as path from "pathe"; +import { readPackageJSON } from "pkg-types"; +import { defineCommand, runMain } from "politty"; +import { z } from "zod"; +import { seedApplyCommand } from "./apply"; +import { commonArgs } from "./shared/args"; +import { logger } from "./shared/logger"; +import { seedValidateCommand } from "./validate"; + +function hasFormat(error: unknown): error is { format(): string } { + return ( + typeof error === "object" && + error !== null && + typeof (error as { format?: unknown }).format === "function" + ); +} + +const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = await readPackageJSON(packageRoot); + +const mainCommand = defineCommand({ + name: "tailor-seed", + description: + "Seed TailorDB and IdP data from JSONL files generated by seedPlugin.\n" + + "Tailor CLI plugin: installed alongside the Tailor CLI, it runs as `tailor seed `.", + subCommands: { + apply: seedApplyCommand, + validate: seedValidateCommand, + }, +}); + +void runMain(mainCommand, { + version: packageJson.version ?? "0.0.0", + // strip unknown keys + globalArgs: z.object(commonArgs), + displayErrors: false, + // Render the SDK's CLIError format (details/suggestion) like the host CLI does. + cleanup: ({ error }) => { + if (!error) return; + if (hasFormat(error)) { + logger.log(error.format()); + } else if (error instanceof Error) { + logger.error(error.message); + } else { + logger.error(`Unknown error: ${String(error)}`); + } + if (error instanceof Error && error.stack) { + logger.debug(`\nStack trace:\n${error.stack}`); + } + }, +}); diff --git a/packages/sdk-plugin-seed/src/jsonl.test.ts b/packages/sdk-plugin-seed/src/jsonl.test.ts new file mode 100644 index 0000000000..76a48b9c68 --- /dev/null +++ b/packages/sdk-plugin-seed/src/jsonl.test.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "pathe"; +import { afterEach, describe, expect, test } from "vitest"; +import { loadSeedData } from "./jsonl"; + +let tempDir: string | undefined; + +function makeDataDir(files: Record): string { + tempDir = mkdtempSync(path.join(tmpdir(), "seed-jsonl-")); + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(tempDir, name), content); + } + return tempDir; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +describe("loadSeedData", () => { + test("parses one JSON record per line", () => { + const dir = makeDataDir({ "User.jsonl": '{"id":1}\n{"id":2}\n' }); + expect(loadSeedData(dir, ["User"])).toEqual({ User: [{ id: 1 }, { id: 2 }] }); + }); + + test("loads missing and empty files as empty lists", () => { + const dir = makeDataDir({ "Empty.jsonl": "\n" }); + expect(loadSeedData(dir, ["Empty", "Missing"])).toEqual({ Empty: [], Missing: [] }); + }); + + test("throws on malformed JSON lines", () => { + const dir = makeDataDir({ "Bad.jsonl": "not-json\n" }); + expect(() => loadSeedData(dir, ["Bad"])).toThrow(SyntaxError); + }); +}); diff --git a/packages/sdk-plugin-seed/src/jsonl.ts b/packages/sdk-plugin-seed/src/jsonl.ts new file mode 100644 index 0000000000..0b8b00ad7c --- /dev/null +++ b/packages/sdk-plugin-seed/src/jsonl.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import * as path from "pathe"; +import type { SeedData } from "@tailor-platform/sdk/cli"; + +/** + * Load seed rows from `/.jsonl` for each type. Missing + * files load as empty lists. + * @param dataDir - Directory containing the JSONL files + * @param typeNames - Type names to load + * @returns Seed rows per type + */ +export function loadSeedData(dataDir: string, typeNames: string[]): SeedData { + const data: SeedData = {}; + for (const typeName of typeNames) { + const jsonlPath = path.join(dataDir, `${typeName}.jsonl`); + let content: string; + try { + content = readFileSync(jsonlPath, "utf-8").trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + data[typeName] = []; + continue; + } + throw error; + } + data[typeName] = content + ? content.split("\n").map((line) => JSON.parse(line) as SeedData[string][number]) + : []; + } + return data; +} diff --git a/packages/sdk-plugin-seed/src/shared/args.ts b/packages/sdk-plugin-seed/src/shared/args.ts new file mode 100644 index 0000000000..5ed9b5dee0 --- /dev/null +++ b/packages/sdk-plugin-seed/src/shared/args.ts @@ -0,0 +1,119 @@ +import * as fs from "node:fs"; +import { parseEnv } from "node:util"; +import * as path from "pathe"; +import { arg } from "politty"; +import { z } from "zod"; +import { logger } from "./logger"; + +type ArgsShape = Record; + +type EnvFileArg = string | string[] | undefined; + +/** + * Load env files from parsed arguments, following Node.js --env-file behavior: + * variables already set in the environment are not overwritten, and variables + * from later files override those from earlier files. + * @param envFiles - Required env file path(s) that must exist + * @param envFilesIfExists - Optional env file path(s) that are loaded if they exist + */ +function loadEnvFiles(envFiles: EnvFileArg, envFilesIfExists: EnvFileArg): void { + const originalEnvKeys = new Set(Object.keys(process.env)); + + const load = (files: EnvFileArg, required: boolean) => { + for (const file of [files ?? []].flat()) { + const envPath = path.resolve(process.cwd(), file); + if (!fs.existsSync(envPath)) { + if (required) { + throw new Error(`Environment file not found: ${envPath}`); + } + continue; + } + const content = fs.readFileSync(envPath, "utf-8"); + const parsed = parseEnv(content); + for (const [key, value] of Object.entries(parsed)) { + if (originalEnvKeys.has(key)) { + continue; + } + process.env[key] = value; + } + } + }; + + load(envFiles, true); + load(envFilesIfExists, false); +} + +/** + * Common arguments shared with the host Tailor CLI so that forwarded global + * flags parse identically when dispatched as a plugin. + */ +export const commonArgs = { + "env-file": arg(z.string().optional(), { + alias: "e", + description: "Path to the environment file (error if not found)", + completion: { type: "file", matcher: [".env.*", ".env"] }, + }), + "env-file-if-exists": arg(z.string().optional(), { + description: "Path to the environment file (ignored if not found)", + completion: { type: "file", matcher: [".env.*", ".env"] }, + effect: (_value, { args }) => { + loadEnvFiles( + args["env-file"] as string | undefined, + args["env-file-if-exists"] as string | undefined, + ); + }, + }), + verbose: arg(z.boolean().default(false), { + description: "Enable verbose logging", + effect: (value) => { + logger.verbose = value; + }, + }), + json: arg(z.boolean().default(false), { + alias: "j", + description: "Output as JSON", + effect: (value) => { + logger.jsonMode = value; + }, + }), +} satisfies ArgsShape; + +/** + * Arguments for commands that require workspace context + */ +export const workspaceArgs = { + "workspace-id": arg(z.string().optional(), { + alias: "w", + description: "Workspace ID", + env: "TAILOR_PLATFORM_WORKSPACE_ID", + completion: { type: "none" }, + }), + profile: arg(z.string().optional(), { + alias: "p", + description: "Workspace profile", + env: "TAILOR_PLATFORM_PROFILE", + completion: { type: "none" }, + }), +} satisfies ArgsShape; + +/** + * Shared config arg for commands that accept a config file path + */ +export const configArg = { + config: arg(z.string().default("tailor.config.ts"), { + alias: "c", + description: "Path to Tailor config file", + env: "TAILOR_CONFIG_PATH", + completion: { type: "file", extensions: ["ts"] }, + }), +} satisfies ArgsShape; + +/** + * Arguments for commands that interact with deployed resources (includes config) + */ +export const deploymentArgs = { + ...workspaceArgs, + ...configArg, +} satisfies ArgsShape; + +export type CommonArgsType = z.infer>; diff --git a/packages/sdk-plugin-seed/src/shared/command.ts b/packages/sdk-plugin-seed/src/shared/command.ts new file mode 100644 index 0000000000..ac63dfb597 --- /dev/null +++ b/packages/sdk-plugin-seed/src/shared/command.ts @@ -0,0 +1,9 @@ +import { createDefineCommand } from "politty"; +import type { CommonArgsType } from "./args"; + +/** + * defineCommand with global args type (CommonArgsType). + * Use this for leaf commands with `run` to get type-safe access to global args. + * Parent commands with only `subCommands` can use `defineCommand` from politty directly. + */ +export const defineAppCommand = createDefineCommand(); diff --git a/packages/sdk-plugin-seed/src/shared/logger.ts b/packages/sdk-plugin-seed/src/shared/logger.ts new file mode 100644 index 0000000000..24c4e6f177 --- /dev/null +++ b/packages/sdk-plugin-seed/src/shared/logger.ts @@ -0,0 +1,97 @@ +import chalk from "chalk"; + +export type LogMode = "default" | "stream" | "plain"; + +export interface LogOptions { + /** Output mode (default: "default") */ + mode?: LogMode; +} + +const TYPE_ICONS: Record = { + info: "ℹ", + success: "✔", + warn: "⚠", + error: "✖", + log: "", +}; + +const TYPE_COLORS: Record string> = { + info: chalk.cyan, + success: chalk.green, + warn: chalk.yellow, + error: chalk.red, + log: (text) => text, +}; + +// In JSON mode, all logs go to stderr to keep stdout clean for JSON data +let _jsonMode = false; +let _verbose = false; + +function writeLog(type: string, message: string, opts?: LogOptions): void { + const mode = opts?.mode ?? "default"; + const colorFn = TYPE_COLORS[type] ?? ((text: string) => text); + + if (mode === "plain") { + process.stderr.write(`${colorFn(message)}\n`); + return; + } + + const icon = TYPE_ICONS[type] ?? ""; + const prefix = icon ? `${icon} ` : ""; + const timestamp = mode === "stream" ? `${new Date().toLocaleTimeString()} ` : ""; + process.stderr.write(`${timestamp}${colorFn(`${prefix}${message}`)}\n`); +} + +export const logger = { + get jsonMode(): boolean { + return _jsonMode; + }, + set jsonMode(value: boolean) { + _jsonMode = value; + }, + + get verbose(): boolean { + return _verbose; + }, + set verbose(value: boolean) { + _verbose = value; + }, + + info(message: string, opts?: LogOptions): void { + writeLog("info", message, opts); + }, + + success(message: string, opts?: LogOptions): void { + writeLog("success", message, opts); + }, + + warn(message: string, opts?: LogOptions): void { + writeLog("warn", message, opts); + }, + + error(message: string, opts?: LogOptions): void { + writeLog("error", message, opts); + }, + + log(message: string): void { + writeLog("log", message, { mode: "plain" }); + }, + + newline(): void { + process.stderr.write("\n"); + }, + + debug(message: string): void { + if (_verbose) { + writeLog("log", chalk.gray(message), { mode: "plain" }); + } + }, + + out(data: string | object | object[]): void { + if (typeof data === "string") { + process.stdout.write(data.endsWith("\n") ? data : `${data}\n`); + return; + } + process.stdout.write(`${JSON.stringify(data)}\n`); + }, +}; diff --git a/packages/sdk-plugin-seed/src/topo-sort.test.ts b/packages/sdk-plugin-seed/src/topo-sort.test.ts new file mode 100644 index 0000000000..3c82ce9056 --- /dev/null +++ b/packages/sdk-plugin-seed/src/topo-sort.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { topologicalSort } from "./topo-sort"; + +describe("topologicalSort", () => { + test("orders dependencies before dependents", () => { + const sorted = topologicalSort(["Order", "User"], { Order: ["User"], User: [] }); + expect(sorted).toEqual(["User", "Order"]); + }); + + test("ignores dependencies outside the input list", () => { + const sorted = topologicalSort(["Order"], { Order: ["User"] }); + expect(sorted).toEqual(["Order"]); + }); + + test("keeps input order for independent types", () => { + const sorted = topologicalSort(["B", "A"], {}); + expect(sorted).toEqual(["B", "A"]); + }); + + test("terminates on circular dependencies", () => { + const sorted = topologicalSort(["A", "B"], { A: ["B"], B: ["A"] }); + expect(sorted).toHaveLength(2); + expect(sorted).toEqual(expect.arrayContaining(["A", "B"])); + }); +}); diff --git a/packages/sdk-plugin-seed/src/topo-sort.ts b/packages/sdk-plugin-seed/src/topo-sort.ts new file mode 100644 index 0000000000..60d5d39ce5 --- /dev/null +++ b/packages/sdk-plugin-seed/src/topo-sort.ts @@ -0,0 +1,27 @@ +/** + * Sort types so that every type comes after the dependencies that are also in + * the input list. Dependencies outside the list are ignored. + * @param types - Type names to sort + * @param deps - Seed dependencies (referenced type names) per type + * @returns Type names in dependency order + */ +export function topologicalSort(types: string[], deps: Record): string[] { + const visited = new Set(); + const result: string[] = []; + + const visit = (type: string): void => { + if (visited.has(type)) return; + visited.add(type); + for (const dep of deps[type] ?? []) { + if (types.includes(dep)) { + visit(dep); + } + } + result.push(type); + }; + + for (const type of types) { + visit(type); + } + return result; +} diff --git a/packages/sdk-plugin-seed/src/validate.ts b/packages/sdk-plugin-seed/src/validate.ts new file mode 100644 index 0000000000..55114fabd9 --- /dev/null +++ b/packages/sdk-plugin-seed/src/validate.ts @@ -0,0 +1,40 @@ +import { loadSeedContext } from "@tailor-platform/sdk/cli"; +import * as path from "pathe"; +import { arg } from "politty"; +import { z } from "zod"; +import { configArg } from "./shared/args"; +import { defineAppCommand } from "./shared/command"; +import { logger } from "./shared/logger"; + +export const seedValidateCommand = defineAppCommand({ + name: "validate", + description: "Validate JSONL seed data against generated schema definitions.", + args: z.strictObject({ + ...configArg, + path: arg(z.string().optional(), { + positional: true, + description: + "File or directory to validate (default: the data directory under the seedPlugin distPath)", + completion: { type: "file", extensions: ["jsonl"] }, + }), + }), + run: async (args) => { + const { validateSeedData } = await import("@tailor-platform/sdk/seed"); + + let targetPath: string; + if (args.path) { + targetPath = path.resolve(process.cwd(), args.path); + } else { + const context = await loadSeedContext({ configPath: args.config }); + targetPath = path.join(context.distPath, "data"); + } + + const result = await validateSeedData({ path: targetPath, verbose: args.verbose }); + if (result.output) { + logger.log(result.output); + } + if (!result.valid) { + throw new Error(result.error); + } + }, +}); diff --git a/packages/sdk-plugin-seed/tsconfig.json b/packages/sdk-plugin-seed/tsconfig.json new file mode 100644 index 0000000000..4bafc4f43a --- /dev/null +++ b/packages/sdk-plugin-seed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"], + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo" + }, + "include": ["src/**/*.ts", "tsdown.config.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sdk-plugin-seed/tsdown.config.ts b/packages/sdk-plugin-seed/tsdown.config.ts new file mode 100644 index 0000000000..a77e89e4cb --- /dev/null +++ b/packages/sdk-plugin-seed/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + target: "node22", + platform: "node", + clean: true, + outDir: "dist", + tsconfig: "./tsconfig.json", + deps: { neverBundle: [/^@tailor-platform\/sdk$/, /^@tailor-platform\/sdk\//] }, + outExtensions: () => ({ + js: ".js", + }), +}); diff --git a/packages/sdk-plugin-seed/vitest.config.ts b/packages/sdk-plugin-seed/vitest.config.ts new file mode 100644 index 0000000000..7878e9578f --- /dev/null +++ b/packages/sdk-plugin-seed/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["src/**/?(*.)+(spec|test).ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + }, + ], + environment: "node", + globals: true, + watch: false, + }, +}); diff --git a/packages/sdk/docs/cli-reference.md b/packages/sdk/docs/cli-reference.md index 20f3945c89..ccff095db9 100644 --- a/packages/sdk/docs/cli-reference.md +++ b/packages/sdk/docs/cli-reference.md @@ -119,6 +119,13 @@ remaining arguments: tailor hello world --loud ``` +This is how the `@tailor-platform/sdk-plugin-seed` package provides the `seed` commands: + +```bash +# Runs `tailor-seed` with: apply +tailor seed apply +``` + This also works under a built-in command group. The command path is joined with hyphens, so a plugin nested under `tailordb` is named `tailor-tailordb-erd`. This is how the `@tailor-platform/sdk-plugin-tailordb-erd` diff --git a/packages/sdk/docs/cli-reference.template.md b/packages/sdk/docs/cli-reference.template.md index 410dfb83fb..e59ba180e5 100644 --- a/packages/sdk/docs/cli-reference.template.md +++ b/packages/sdk/docs/cli-reference.template.md @@ -112,6 +112,13 @@ remaining arguments: tailor hello world --loud ``` +This is how the `@tailor-platform/sdk-plugin-seed` package provides the `seed` commands: + +```bash +# Runs `tailor-seed` with: apply +tailor seed apply +``` + This also works under a built-in command group. The command path is joined with hyphens, so a plugin nested under `tailordb` is named `tailor-tailordb-erd`. This is how the `@tailor-platform/sdk-plugin-tailordb-erd` diff --git a/packages/sdk/docs/configuration.md b/packages/sdk/docs/configuration.md index 27f0618d14..0b065e6f4b 100644 --- a/packages/sdk/docs/configuration.md +++ b/packages/sdk/docs/configuration.md @@ -108,7 +108,7 @@ When using external resources: - The resource must be deployed and available before referencing it - You can combine external resources with locally-defined resources - TailorDB type names must remain unique across local and external TailorDB namespaces; `deploy` checks external TailorDB type names before applying changes -- Destructive operations like `tailordb truncate` (and `seedPlugin`'s `seed:reset`) automatically exclude external resources to prevent accidental data loss in shared resources +- Destructive operations like `tailordb truncate` (and `tailor seed apply --truncate`) automatically exclude external resources to prevent accidental data loss in shared resources ### Built-in IdP diff --git a/packages/sdk/docs/migration/v2.md b/packages/sdk/docs/migration/v2.md index 2c50bf3128..ed495071b4 100644 --- a/packages/sdk/docs/migration/v2.md +++ b/packages/sdk/docs/migration/v2.md @@ -1185,6 +1185,46 @@ single generation pass and resolves once it completes. +## Generated seed exec.mjs → tailor seed CLI plugin + +**Migration:** Manual + +`seedPlugin` no longer generates the `exec.mjs` seed runner. Seeding and validation move to the `tailor seed` commands provided by the `@tailor-platform/sdk-plugin-seed` CLI plugin: install it as a devDependency, replace `node /exec.mjs` invocations with `tailor seed apply` and `node /exec.mjs validate` with `tailor seed validate`, and delete the stale generated `/exec.mjs` file. Seed data and schema generation (`data/*.jsonl`, `data/*.schema.ts`) is unchanged, and the `tailor seed apply` options mirror the old script (`--machine-user`, `--namespace`, `--skip-idp`, `--truncate`, `--yes`, type-name arguments). + +Before: + +```jsonc +"seed": "node ./seed/exec.mjs", +"seed:validate": "node ./seed/exec.mjs validate" +``` + +After: + +```jsonc +"seed": "tailor seed apply", +"seed:validate": "tailor seed validate" +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +seedPlugin no longer generates the exec.mjs seed runner in v2. The tailor seed +CLI plugin (@tailor-platform/sdk-plugin-seed) replaces it: + +- Install @tailor-platform/sdk-plugin-seed as a devDependency next to + @tailor-platform/sdk. +- Replace `node /exec.mjs [options] [types...]` invocations with + `tailor seed apply [options] [types...]` (same options: --machine-user/-m, + --namespace/-n, --skip-idp, --truncate, --yes, and type-name arguments). +- Replace `node /exec.mjs validate [path]` with + `tailor seed validate [path]`. +- Delete the stale generated `/exec.mjs` file; keep the data/ + directory (JSONL data and generated schemas) as-is. +``` + +
+ ## Behavioral changes (no migration required) These v2 changes alter runtime or CLI behavior; no source change is needed. diff --git a/packages/sdk/src/cli/commands/generate/service.ts b/packages/sdk/src/cli/commands/generate/service.ts index 750007bdc3..945fd518e8 100644 --- a/packages/sdk/src/cli/commands/generate/service.ts +++ b/packages/sdk/src/cli/commands/generate/service.ts @@ -7,6 +7,7 @@ import { } from "#/cli/services/application"; import { createExecutorService } from "#/cli/services/executor/service"; import { assertUniqueLocalTailorDBTypeNames } from "#/cli/services/tailordb/type-name-validation"; +import { getAuthInput } from "#/cli/shared/auth-input"; import { loadConfig, type LoadedConfig } from "#/cli/shared/config-loader"; import { getDistDir } from "#/cli/shared/dist-dir"; import { logger, styles } from "#/cli/shared/logger"; @@ -16,7 +17,6 @@ import { PluginManager } from "#/plugin/manager"; import { assertDefined } from "#/utils/assert"; import type { TypeSourceInfo, TailorDBType } from "#/parser/service/tailordb/types"; import type { - GeneratorAuthInput, GeneratorResult, TailorDBNamespaceData, ResolverNamespaceData, @@ -73,27 +73,6 @@ export function createGenerationManager(params: { // Get plugins that have generation hooks const generationPlugins = pluginManager?.getPluginsWithGenerationHooks() ?? []; - function getAuthInput(): GeneratorAuthInput | undefined { - const authService = application.authService; - if (!authService) return undefined; - - const authConfig = authService.config; - const userProfile = authService.userProfile; - return { - name: authConfig.name, - userProfile: userProfile - ? { - typeName: userProfile.type.name, - namespace: userProfile.namespace, - usernameField: userProfile.usernameField, - } - : undefined, - machineUsers: authConfig.machineUsers, - oauth2Clients: authConfig.oauth2Clients, - idProvider: authConfig.idProvider, - }; - } - // ========================================================================= // Plugin phase-complete hook runner // ========================================================================= @@ -136,7 +115,7 @@ export function createGenerationManager(params: { if (!hook) return; const pluginBaseDir = path.join(baseDir, plugin.id); - const auth = getAuthInput(); + const auth = getAuthInput(application); const tailordb = buildTailorDBData(); let result: GeneratorResult; diff --git a/packages/sdk/src/cli/lib.ts b/packages/sdk/src/cli/lib.ts index b2112a7f04..79c2a5a91b 100644 --- a/packages/sdk/src/cli/lib.ts +++ b/packages/sdk/src/cli/lib.ts @@ -221,7 +221,19 @@ export { export { MIGRATION_LABEL_KEY } from "./commands/tailordb/migrate/types"; // Seed exports -export { chunkSeedData, type SeedChunk, type ChunkSeedDataOptions } from "./shared/seed-chunker"; +export { + loadSeedContext, + type LoadSeedContextOptions, + type SeedContext, + type SeedIdpUserContext, + type SeedNamespaceConfig, +} from "./shared/seed-context"; +export { + chunkSeedData, + type SeedChunk, + type ChunkSeedDataOptions, + type SeedData, +} from "./shared/seed-chunker"; export { bundleSeedScript, type SeedBundleResult } from "./commands/generate/seed/bundler"; export { bundleMigrationScript, @@ -232,6 +244,7 @@ export { waitForExecution, type ScriptExecutionOptions, type ScriptExecutionResult, + type ScriptInvoker, type ExecutionWaitResult, } from "./shared/script-executor"; export { initOperatorClient, type OperatorClient } from "./shared/client"; diff --git a/packages/sdk/src/cli/shared/auth-input.ts b/packages/sdk/src/cli/shared/auth-input.ts new file mode 100644 index 0000000000..2a24b80471 --- /dev/null +++ b/packages/sdk/src/cli/shared/auth-input.ts @@ -0,0 +1,29 @@ +import type { Application } from "#/cli/services/application"; +import type { GeneratorAuthInput } from "#/plugin/types"; + +/** + * Build the auth input passed to generator plugins from an application's + * auth service. + * @param application - Application instance to read the auth service from + * @returns Auth input for generator plugins, or undefined when the config has no auth + */ +export function getAuthInput(application: Application): GeneratorAuthInput | undefined { + const authService = application.authService; + if (!authService) return undefined; + + const authConfig = authService.config; + const userProfile = authService.userProfile; + return { + name: authConfig.name, + userProfile: userProfile + ? { + typeName: userProfile.type.name, + namespace: userProfile.namespace, + usernameField: userProfile.usernameField, + } + : undefined, + machineUsers: authConfig.machineUsers, + oauth2Clients: authConfig.oauth2Clients, + idProvider: authConfig.idProvider, + }; +} diff --git a/packages/sdk/src/cli/shared/plugin.ts b/packages/sdk/src/cli/shared/plugin.ts index 1f6672d20e..acc9b2976a 100644 --- a/packages/sdk/src/cli/shared/plugin.ts +++ b/packages/sdk/src/cli/shared/plugin.ts @@ -19,6 +19,7 @@ import { readPackageJson } from "./package-json"; * install command when the plugin executable is not found. */ const KNOWN_PLUGIN_PACKAGES: Record = { + seed: "@tailor-platform/sdk-plugin-seed", "tailordb-erd": "@tailor-platform/sdk-plugin-tailordb-erd", }; diff --git a/packages/sdk/src/cli/shared/script-executor.ts b/packages/sdk/src/cli/shared/script-executor.ts index db1c60aabb..1a8ab0ab56 100644 --- a/packages/sdk/src/cli/shared/script-executor.ts +++ b/packages/sdk/src/cli/shared/script-executor.ts @@ -7,9 +7,16 @@ import { FunctionExecution_Status } from "@tailor-platform/tailor-proto/function_resource_pb"; import type { OperatorClient } from "#/cli/shared/client"; -import type { AuthInvoker } from "@tailor-platform/tailor-proto/auth_resource_pb"; +import type { MessageInitShape } from "@bufbuild/protobuf"; +import type { AuthInvokerSchema } from "@tailor-platform/tailor-proto/auth_resource_pb"; import type { Jsonifiable } from "type-fest"; +/** + * Auth invoker for script execution: a plain init object or a constructed + * AuthInvoker message. + */ +export type ScriptInvoker = MessageInitShape; + /** * Default polling interval for script execution status in milliseconds (1 second) */ @@ -30,7 +37,7 @@ export interface ScriptExecutionOptions { /** Optional JSON-serializable argument to pass to the script */ arg?: T; /** Auth invoker for script execution */ - invoker: AuthInvoker; + invoker: ScriptInvoker; /** Polling interval in milliseconds (default: 1000ms) */ pollInterval?: number; } diff --git a/packages/sdk/src/cli/shared/seed-chunker.ts b/packages/sdk/src/cli/shared/seed-chunker.ts index bb63cd4179..7c525e310e 100644 --- a/packages/sdk/src/cli/shared/seed-chunker.ts +++ b/packages/sdk/src/cli/shared/seed-chunker.ts @@ -6,11 +6,12 @@ */ import { assertDefined } from "#/utils/assert"; +import type { JsonObject } from "type-fest"; /** * Seed data keyed by type name, with an array of records per type. */ -export type SeedData = Record[]>; +export type SeedData = Record; /** * A single chunk of seed data with metadata for ordered execution. @@ -114,7 +115,7 @@ export function chunkSeedData(options: ChunkSeedDataOptions): SeedChunk[] { currentOrder = []; } - let recordBatch: Record[] = []; + let recordBatch: JsonObject[] = []; for (const record of typeRecords) { if (byteSize(JSON.stringify({ data: { [type]: [record] }, order: [type] })) > argBudget) { const singleRecordSize = byteSize(JSON.stringify(record)); diff --git a/packages/sdk/src/cli/shared/seed-context.test.ts b/packages/sdk/src/cli/shared/seed-context.test.ts new file mode 100644 index 0000000000..abedf93b31 --- /dev/null +++ b/packages/sdk/src/cli/shared/seed-context.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test, vi } from "vitest"; +import { loadSeedContext } from "./seed-context"; +import type { Application } from "#/cli/services/application"; +import type { TailorDBType } from "#/parser/service/tailordb/types"; +import type { Plugin, TailorDBNamespaceData } from "#/plugin/types"; +import type { LoadedApplicationNamespaces } from "./tailordb-namespaces"; + +const loadApplicationNamespaces = vi.hoisted(() => + vi.fn<() => Promise>(), +); + +vi.mock("./tailordb-namespaces", () => ({ loadApplicationNamespaces })); + +function fakeType(name: string, fields: Record): TailorDBType { + return { name, fields } as unknown as TailorDBType; +} + +function fakeNamespace(namespace: string, types: TailorDBType[]): TailorDBNamespaceData { + return { + namespace, + types: Object.fromEntries(types.map((type) => [type.name, type])), + sourceInfo: new Map(), + pluginAttachments: new Map(), + }; +} + +type FakeLoadResult = { + plugins?: Plugin[]; + application?: Partial; + namespaces?: TailorDBNamespaceData[]; +}; + +function mockLoadResult(result: FakeLoadResult): void { + loadApplicationNamespaces.mockResolvedValue({ + config: { path: "/proj/tailor.config.ts" }, + plugins: result.plugins ?? [], + application: result.application ?? {}, + namespaces: result.namespaces ?? [], + } as LoadedApplicationNamespaces); +} + +function seedPluginInstance(pluginConfig: object): Plugin { + return { id: "@tailor-platform/seed", pluginConfig } as Plugin; +} + +describe("loadSeedContext", () => { + test("throws an actionable error when seedPlugin is not configured", async () => { + mockLoadResult({ plugins: [{ id: "@tailor-platform/other" } as Plugin] }); + + await expect(loadSeedContext()).rejects.toThrow( + /seedPlugin is not configured in \/proj\/tailor\.config\.ts/, + ); + }); + + test("resolves a relative distPath against the config directory", async () => { + mockLoadResult({ plugins: [seedPluginInstance({ distPath: "./seed" })] }); + + const context = await loadSeedContext(); + expect(context.distPath).toBe("/proj/seed"); + expect(context.machineUserName).toBeUndefined(); + expect(context.idpUser).toBeNull(); + }); + + test("keeps an absolute distPath and the configured machine user", async () => { + mockLoadResult({ + plugins: [seedPluginInstance({ distPath: "/out/seed", machineUserName: "admin" })], + }); + + const context = await loadSeedContext(); + expect(context.distPath).toBe("/out/seed"); + expect(context.machineUserName).toBe("admin"); + }); + + test("builds per-namespace seed ordering from relations", async () => { + const user = fakeType("User", { id: { config: {} } }); + const order = fakeType("Order", { + user: { relation: { targetType: "User" }, config: {} }, + parent: { config: { foreignKeyType: "Order" } }, + }); + mockLoadResult({ + plugins: [seedPluginInstance({ distPath: "./seed" })], + namespaces: [fakeNamespace("main-db", [user, order])], + }); + + const context = await loadSeedContext(); + expect(context.namespaces).toEqual([ + { + namespace: "main-db", + types: ["User", "Order"], + dependencies: { User: [], Order: ["User"] }, + selfRefTypes: ["Order"], + }, + ]); + }); + + test("includes IdP user context for BuiltInIdP with a user profile", async () => { + mockLoadResult({ + plugins: [seedPluginInstance({ distPath: "./seed" })], + application: { + authService: { + config: { + name: "main-auth", + machineUsers: {}, + idProvider: { kind: "BuiltInIdP", namespace: "main-idp" }, + }, + userProfile: { + type: { name: "Staff" }, + namespace: "main-db", + usernameField: "email", + }, + } as unknown as Application["authService"], + }, + }); + + const context = await loadSeedContext(); + expect(context.idpUser).not.toBeNull(); + expect(context.idpUser?.idpNamespace).toBe("main-idp"); + expect(context.idpUser?.seedScriptCode).toContain('namespace: "main-idp"'); + expect(context.idpUser?.truncateScriptCode).toContain('namespace: "main-idp"'); + }); + + test("omits IdP user context when the IdP is not built-in", async () => { + mockLoadResult({ + plugins: [seedPluginInstance({ distPath: "./seed" })], + application: { + authService: { + config: { + name: "main-auth", + machineUsers: {}, + idProvider: { kind: "OIDC", namespace: "ext-idp" }, + }, + userProfile: undefined, + } as unknown as Application["authService"], + }, + }); + + const context = await loadSeedContext(); + expect(context.idpUser).toBeNull(); + }); +}); diff --git a/packages/sdk/src/cli/shared/seed-context.ts b/packages/sdk/src/cli/shared/seed-context.ts new file mode 100644 index 0000000000..378e9507b8 --- /dev/null +++ b/packages/sdk/src/cli/shared/seed-context.ts @@ -0,0 +1,94 @@ +import * as path from "pathe"; +import { + generateIdpSeedScriptCode, + generateIdpTruncateScriptCode, + processIdpUser, +} from "#/plugin/builtin/seed/idp-user-processor"; +import { SeedGeneratorID, type SeedPluginOptions } from "#/plugin/builtin/seed/index"; +import { + buildSeedNamespaceConfigs, + type SeedNamespaceConfig, +} from "#/plugin/builtin/seed/seed-type-processor"; +import { getAuthInput } from "./auth-input"; +import { loadApplicationNamespaces } from "./tailordb-namespaces"; +import type { LoadedConfig } from "./config-loader"; + +export type { SeedNamespaceConfig }; + +/** + * IdP `_User` seeding context, present when the config uses the built-in IdP + * with a user profile type. + */ +export interface SeedIdpUserContext { + /** IdP namespace the `_User` records belong to. */ + idpNamespace: string; + /** Server-side script that creates `_User` records from seed rows. */ + seedScriptCode: string; + /** Server-side script that deletes all `_User` records. */ + truncateScriptCode: string; +} + +/** + * Everything a seed run needs from the local config: the seed data location, + * per-namespace seeding order, and IdP user context. + */ +export interface SeedContext { + /** The loaded Tailor config. */ + config: LoadedConfig; + /** Absolute path to the seedPlugin output directory. */ + distPath: string; + /** Default machine user name from seedPlugin options, if configured. */ + machineUserName?: string | undefined; + /** Seed ordering information per TailorDB namespace. */ + namespaces: SeedNamespaceConfig[]; + /** IdP `_User` seeding context, or null when not applicable. */ + idpUser: SeedIdpUserContext | null; +} + +/** + * Options for {@link loadSeedContext}. + */ +export interface LoadSeedContextOptions { + /** Path to tailor.config.ts. Defaults to searching from the current directory. */ + configPath?: string; +} + +/** + * Load the seed context from the local config. Requires `seedPlugin` to be + * configured in the config's plugins. A relative `distPath` in the seedPlugin + * options is resolved against the config file's directory. + * @param options - Seed context loading options. + * @returns The seed context computed from the local config. + */ +export async function loadSeedContext(options: LoadSeedContextOptions = {}): Promise { + const { config, plugins, application, namespaces } = await loadApplicationNamespaces({ + configPath: options.configPath, + }); + + const seedPlugin = plugins.find((plugin) => plugin.id === SeedGeneratorID); + if (!seedPlugin) { + throw new Error( + `seedPlugin is not configured in ${config.path}. ` + + 'Add seedPlugin({ distPath: "./seed" }) from "@tailor-platform/sdk/plugin/seed" to definePlugins().', + ); + } + const pluginOptions = seedPlugin.pluginConfig as SeedPluginOptions; + + const authInput = getAuthInput(application); + const idpUserMeta = authInput ? processIdpUser(authInput) : undefined; + const idpUser: SeedIdpUserContext | null = idpUserMeta + ? { + idpNamespace: idpUserMeta.idpNamespace, + seedScriptCode: generateIdpSeedScriptCode(idpUserMeta.idpNamespace), + truncateScriptCode: generateIdpTruncateScriptCode(idpUserMeta.idpNamespace), + } + : null; + + return { + config, + distPath: path.resolve(path.dirname(config.path), pluginOptions.distPath), + machineUserName: pluginOptions.machineUserName, + namespaces: buildSeedNamespaceConfigs(namespaces), + idpUser, + }; +} diff --git a/packages/sdk/src/cli/shared/tailordb-namespaces.ts b/packages/sdk/src/cli/shared/tailordb-namespaces.ts index c919cf9874..fb86f1a7b1 100644 --- a/packages/sdk/src/cli/shared/tailordb-namespaces.ts +++ b/packages/sdk/src/cli/shared/tailordb-namespaces.ts @@ -1,8 +1,8 @@ -import { defineApplication } from "#/cli/services/application"; +import { defineApplication, type Application } from "#/cli/services/application"; import { PluginManager } from "#/plugin/manager"; import { loadConfig, type LoadedConfig } from "./config-loader"; import { generateUserTypes } from "./type-generator"; -import type { TailorDBNamespaceData } from "#/plugin/types"; +import type { Plugin, TailorDBNamespaceData } from "#/plugin/types"; /** * Namespace selection for {@link loadTailorDBNamespaces}: explicit namespace @@ -32,15 +32,25 @@ export interface LoadedTailorDBNamespaces { } /** - * Load local TailorDB namespaces exactly as SDK generation/deploy sees them: - * the config is loaded, user types are generated, and each selected - * namespace's types are loaded with namespace plugins applied. + * Result of {@link loadApplicationNamespaces}: the loaded namespaces plus the + * config plugins and application they were loaded through. + */ +export interface LoadedApplicationNamespaces extends LoadedTailorDBNamespaces { + /** Plugins declared in the loaded config. */ + plugins: Plugin[]; + /** Application defined from the loaded config. */ + application: Application; +} + +/** + * Load local TailorDB namespaces along with the config plugins and the + * defined application. Internal superset of {@link loadTailorDBNamespaces}. * @param options - Namespace loading options. - * @returns The loaded config and TailorDB namespace data. + * @returns The loaded config, plugins, application, and TailorDB namespace data. */ -export async function loadTailorDBNamespaces( +export async function loadApplicationNamespaces( options: LoadTailorDBNamespacesOptions = {}, -): Promise { +): Promise { const { config, plugins } = await loadConfig(options.configPath); await generateUserTypes({ config, configPath: config.path }); @@ -80,5 +90,19 @@ export async function loadTailorDBNamespaces( }); } + return { config, plugins, application, namespaces }; +} + +/** + * Load local TailorDB namespaces exactly as SDK generation/deploy sees them: + * the config is loaded, user types are generated, and each selected + * namespace's types are loaded with namespace plugins applied. + * @param options - Namespace loading options. + * @returns The loaded config and TailorDB namespace data. + */ +export async function loadTailorDBNamespaces( + options: LoadTailorDBNamespacesOptions = {}, +): Promise { + const { config, namespaces } = await loadApplicationNamespaces(options); return { config, namespaces }; } diff --git a/packages/sdk/src/plugin/builtin/seed/index.ts b/packages/sdk/src/plugin/builtin/seed/index.ts index fee5193af2..5b3c472259 100644 --- a/packages/sdk/src/plugin/builtin/seed/index.ts +++ b/packages/sdk/src/plugin/builtin/seed/index.ts @@ -1,12 +1,6 @@ import * as path from "pathe"; import { assertDefined } from "#/utils/assert"; -import ml from "#/utils/multiline"; -import { - processIdpUser, - generateIdpUserSchemaFile, - generateIdpSeedScriptCode, - generateIdpTruncateScriptCode, -} from "./idp-user-processor"; +import { processIdpUser, generateIdpUserSchemaFile } from "./idp-user-processor"; import { processLinesDb, generateLinesDbSchemaFile, @@ -14,7 +8,6 @@ import { type PluginSchemaParams, } from "./lines-db-processor"; import { processSeedTypeInfo } from "./seed-type-processor"; -import { escapeSeedScriptCodeForTemplateLiteral } from "./template-literal"; import type { Plugin, GeneratorResult, TailorDBReadyContext } from "#/plugin/types"; /** Unique identifier for the seed generator plugin. */ @@ -39,7 +32,7 @@ type DisableIdpUserSyncDirections = { idpToUser?: boolean; }; -type SeedPluginOptions = { +export type SeedPluginOptions = { distPath: string; machineUserName?: string; /** @@ -61,719 +54,9 @@ function resolveIdpUserSyncFKs(option: SeedPluginOptions["disableIdpUserSync"]): emitIdpToUserFK: !(option?.idpToUser ?? false), }; } - -type NamespaceConfig = { - namespace: string; - types: string[]; - dependencies: Record; - selfRefTypes: string[]; -}; - -/** - * Generate the IdP user seed function code using tailor.idp.Client via testExecScript - * @param hasIdpUser - Whether IdP user is included - * @param idpNamespace - The IDP namespace name - * @returns JavaScript code for IdP user seeding function - */ -function generateIdpUserSeedFunction(hasIdpUser: boolean, idpNamespace: string | null): string { - if (!hasIdpUser || !idpNamespace) return ""; - - const scriptCode = generateIdpSeedScriptCode(idpNamespace); - - return ml` - // Seed _User via tailor.idp.Client (server-side) - const seedIdpUser = async () => { - console.log(styleText("cyan", " Seeding _User via tailor.idp.Client...")); - const dataDir = join(configDir, "data"); - const data = loadSeedData(dataDir, ["_User"]); - const rows = data["_User"] || []; - if (rows.length === 0) { - console.log(styleText("dim", " No _User data to seed")); - return { success: true }; - } - console.log(styleText("dim", \` Processing \${rows.length} _User records...\`)); - - const idpSeedCode = \/* js *\/\`${escapeSeedScriptCodeForTemplateLiteral(scriptCode)}\`; - - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: "seed-idp-user.ts", - code: idpSeedCode, - arg: { users: rows }, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - if (result.logs) { - for (const line of result.logs.split("\\n").filter(Boolean)) { - console.log(styleText("dim", \` \${line}\`)); - } - } - - if (result.success) { - let parsed; - try { - parsed = JSON.parse(result.result || "{}"); - } catch (e) { - console.error(styleText("red", \` ✗ Failed to parse seed result: \${e.message}\`)); - return { success: false }; - } - - if (parsed.processed) { - console.log(styleText("green", \` ✓ _User: \${parsed.processed} rows processed\`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - for (const err of errors) { - console.error(styleText("red", \` ✗ \${err}\`)); - } - return { success: false }; - } - - return { success: true }; - } else { - console.error(styleText("red", \` ✗ Seed failed: \${result.error}\`)); - return { success: false }; - } - }; - `; -} - -/** - * Generate the IdP user seed call code - * @param hasIdpUser - Whether IdP user is included - * @returns JavaScript code for calling IdP user seeding - */ -function generateIdpUserSeedCall(hasIdpUser: boolean): string { - if (!hasIdpUser) return ""; - - return ml` - // Seed _User if included and not skipped - const shouldSeedUser = !skipIdp && (!entitiesToProcess || entitiesToProcess.includes("_User")); - if (hasIdpUser && shouldSeedUser) { - const result = await seedIdpUser(); - if (!result.success) { - allSuccess = false; - } - } - `; -} - -/** - * Generate the IdP user truncation function code using tailor.idp.Client via testExecScript - * @param hasIdpUser - Whether IdP user is included - * @param idpNamespace - The IDP namespace name - * @returns JavaScript code for IdP user truncation function - */ -function generateIdpUserTruncateFunction(hasIdpUser: boolean, idpNamespace: string | null): string { - if (!hasIdpUser || !idpNamespace) return ""; - - const scriptCode = generateIdpTruncateScriptCode(idpNamespace); - - return ml` - // Truncate _User via tailor.idp.Client (server-side) - const truncateIdpUser = async () => { - console.log(styleText("cyan", "Truncating _User via tailor.idp.Client...")); - - const idpTruncateCode = \/* js *\/\`${escapeSeedScriptCodeForTemplateLiteral(scriptCode)}\`; - - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: "truncate-idp-user.ts", - code: idpTruncateCode, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - if (result.logs) { - for (const line of result.logs.split("\\n").filter(Boolean)) { - console.log(styleText("dim", \` \${line}\`)); - } - } - - if (result.success) { - let parsed; - try { - parsed = JSON.parse(result.result || "{}"); - } catch (e) { - console.error(styleText("red", \` ✗ Failed to parse truncation result: \${e.message}\`)); - return { success: false }; - } - - if (parsed.deleted !== undefined) { - console.log(styleText("green", \` ✓ _User: \${parsed.deleted} users deleted\`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - for (const err of errors) { - console.error(styleText("red", \` ✗ \${err}\`)); - } - return { success: false }; - } - - return { success: true }; - } else { - console.error(styleText("red", \` ✗ Truncation failed: \${result.error}\`)); - return { success: false }; - } - }; - `; -} - -/** - * Generate the IdP user truncation call code within the truncate block - * @param hasIdpUser - Whether IdP user is included - * @returns JavaScript code for calling IdP user truncation - */ -function generateIdpUserTruncateCall(hasIdpUser: boolean): string { - if (!hasIdpUser) return ""; - - return ml` - // Truncate _User if applicable - const shouldTruncateUser = !skipIdp && !hasNamespace && (!hasTypes || entitiesToProcess.includes("_User")); - if (hasIdpUser && shouldTruncateUser) { - const truncResult = await truncateIdpUser(); - if (!truncResult.success) { - console.error(styleText("red", "IDP user truncation failed.")); - process.exit(1); - } - } - `; -} - -/** - * Generates the exec.mjs script content using testExecScript API for TailorDB types - * and tailor.idp.Client for _User (IdP managed) - * @param defaultMachineUserName - Default machine user name from generator config (can be overridden at runtime) - * @param relativeConfigPath - Config path relative to exec script - * @param namespaceConfigs - Namespace configurations with types and dependencies - * @param hasIdpUser - Whether _User is included - * @param idpNamespace - The IDP namespace name, or null if not applicable - * @returns exec.mjs file contents - */ -function generateExecScript( - defaultMachineUserName: string | undefined, - relativeConfigPath: string, - namespaceConfigs: NamespaceConfig[], - hasIdpUser: boolean, - idpNamespace: string | null, -): string { - // Generate namespaceEntities object - const namespaceEntitiesEntries = namespaceConfigs - .map(({ namespace, types }) => { - const entitiesFormatted = types.map((e) => ` "${e}",`).join("\n"); - return ` "${namespace}": [\n${entitiesFormatted}\n ]`; - }) - .join(",\n"); - - // Generate dependency map for each namespace - const namespaceDepsEntries = namespaceConfigs - .map(({ namespace, dependencies }) => { - const depsObj = Object.entries(dependencies) - .map(([type, deps]) => ` "${type}": [${deps.map((d) => `"${d}"`).join(", ")}]`) - .join(",\n"); - return ` "${namespace}": {\n${depsObj}\n }`; - }) - .join(",\n"); - - // Generate self-referencing types map for each namespace - const namespaceSelfRefEntries = namespaceConfigs - .map(({ namespace, selfRefTypes }) => { - const formatted = selfRefTypes.map((t) => `"${t}"`).join(", "); - return ` "${namespace}": [${formatted}]`; - }) - .join(",\n"); - - return ml /* js */ ` - /** - * @generated - * This file is auto-generated by @tailor-platform/sdk's seedPlugin. - * Do not edit by hand: changes will be overwritten on the next \`sdk generate\`. - */ - import { readFileSync } from "node:fs"; - import { join, isAbsolute } from "node:path"; - import { parseArgs, styleText } from "node:util"; - import { createInterface } from "node:readline"; - import { - show, - truncate, - bundleSeedScript, - chunkSeedData, - executeScript, - initOperatorClient, - loadAccessToken, - loadWorkspaceId, - } from "@tailor-platform/sdk/cli"; - - // Handle "validate" subcommand before parseArgs - const subcommand = process.argv[2]; - if (subcommand === "validate") { - const { validateSeedData } = await import("@tailor-platform/sdk/seed"); - const validateArgs = parseArgs({ - args: process.argv.slice(3), - options: { - verbose: { type: "boolean", short: "v", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, - }); - - if (validateArgs.values.help) { - console.log(\` - Usage: node exec.mjs validate [options] [path] - - Validate JSONL seed data against schema definitions. - - Arguments: - path File or directory to validate (default: ./data) - - Options: - -v, --verbose Show verbose error output - -h, --help Show help - - Examples: - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - node exec.mjs validate -v # Verbose error output - \`); - process.exit(0); - } - - const configDir = import.meta.dirname; - const targetPath = validateArgs.positionals[0] || join(configDir, "data"); - const resolvedPath = isAbsolute(targetPath) ? targetPath : join(process.cwd(), targetPath); - - try { - const result = await validateSeedData({ path: resolvedPath, verbose: validateArgs.values.verbose }); - if (result.output) console.log(result.output); - if (!result.valid) { - console.error(result.error); - process.exit(1); - } - process.exit(0); - } catch (error) { - console.error(styleText("red", \`Error: \${error instanceof Error ? error.message : String(error)}\`)); - process.exit(1); - } - } - - // Parse command-line arguments - const { values, positionals } = parseArgs({ - options: { - "machine-user": { type: "string", short: "m" }, - namespace: { type: "string", short: "n" }, - "skip-idp": { type: "boolean", default: false }, - truncate: { type: "boolean", default: false }, - yes: { type: "boolean", default: false }, - profile: { type: "string", short: "p" }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, - }); - - if (values.help) { - console.log(\` - Usage: node exec.mjs [command] [options] [types...] - - Commands: - validate [path] Validate seed data against schema (default: ./data) - - Options: - -m, --machine-user Machine user name for authentication (required if not configured) - -n, --namespace Process all types in specified namespace (excludes _User) - --skip-idp Skip IdP user (_User) entity - --truncate Truncate tables before seeding - --yes Skip confirmation prompts (for truncate) - -p, --profile Workspace profile name - -h, --help Show help - - Examples: - node exec.mjs -m admin # Process all types with machine user - node exec.mjs --namespace # Process tailordb namespace only (no _User) - node exec.mjs User Order # Process specific types only - node exec.mjs --skip-idp # Process all except _User - node exec.mjs --truncate # Truncate all tables, then seed all - node exec.mjs --truncate --yes # Truncate all tables without confirmation, then seed all - node exec.mjs --truncate --namespace # Truncate tailordb, then seed tailordb - node exec.mjs --truncate User Order # Truncate User and Order, then seed them - node exec.mjs validate # Validate all seed data - node exec.mjs validate ./data/User.jsonl # Validate specific file - \`); - process.exit(0); - } - - // Helper function to prompt for y/n confirmation - const promptConfirmation = (question) => { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(styleText("yellow", question), (answer) => { - rl.close(); - resolve(answer.toLowerCase().trim()); - }); - }); - }; - - const configDir = import.meta.dirname; - const configPath = join(configDir, "${relativeConfigPath}"); - - // Determine machine user name (CLI argument takes precedence over config default) - const defaultMachineUser = ${defaultMachineUserName ? `"${defaultMachineUserName}"` : "undefined"}; - const machineUserName = values["machine-user"] || defaultMachineUser; - - if (!machineUserName) { - console.error(styleText("red", "Error: Machine user name is required.")); - console.error(styleText("yellow", "Specify --machine-user or configure machineUserName in generator options.")); - process.exit(1); - } - - // Entity configuration - const namespaceEntities = { -${namespaceEntitiesEntries} - }; - const namespaceDeps = { -${namespaceDepsEntries} - }; - const namespaceSelfRefTypes = { -${namespaceSelfRefEntries} - }; - const entities = Object.values(namespaceEntities).flat(); - const hasIdpUser = ${String(hasIdpUser)}; - - // Determine which entities to process - let entitiesToProcess = null; - - const hasNamespace = !!values.namespace; - const hasTypes = positionals.length > 0; - const skipIdp = values["skip-idp"]; - - // Validate mutually exclusive options - const optionCount = [hasNamespace, hasTypes].filter(Boolean).length; - if (optionCount > 1) { - console.error(styleText("red", "Error: Options --namespace and type names are mutually exclusive.")); - process.exit(1); - } - - // --skip-idp and --namespace are redundant (namespace already excludes _User) - if (skipIdp && hasNamespace) { - console.warn(styleText("yellow", "Warning: --skip-idp is redundant with --namespace (namespace filtering already excludes _User).")); - } - - // Filter by namespace (automatically excludes _User as it has no namespace) - if (hasNamespace) { - const namespace = values.namespace; - entitiesToProcess = namespaceEntities[namespace]; - - if (!entitiesToProcess || entitiesToProcess.length === 0) { - console.error(styleText("red", \`Error: No entities found in namespace "\${namespace}"\`)); - console.error(styleText("yellow", \`Available namespaces: \${Object.keys(namespaceEntities).join(", ")}\`)); - process.exit(1); - } - - console.log(styleText("cyan", \`Filtering by namespace: \${namespace}\`)); - console.log(styleText("dim", \`Entities: \${entitiesToProcess.join(", ")}\`)); - } - - // Filter by specific types - if (hasTypes) { - const requestedTypes = positionals; - const notFoundTypes = []; - const allTypes = hasIdpUser ? [...entities, "_User"] : entities; - - entitiesToProcess = requestedTypes.filter((type) => { - if (!allTypes.includes(type)) { - notFoundTypes.push(type); - return false; - } - return true; - }); - - if (notFoundTypes.length > 0) { - console.error(styleText("red", \`Error: The following types were not found: \${notFoundTypes.join(", ")}\`)); - console.error(styleText("yellow", \`Available types: \${allTypes.join(", ")}\`)); - process.exit(1); - } - - console.log(styleText("cyan", \`Filtering by types: \${entitiesToProcess.join(", ")}\`)); - } - - // Apply --skip-idp filter - if (skipIdp) { - if (entitiesToProcess) { - entitiesToProcess = entitiesToProcess.filter((entity) => entity !== "_User"); - } else { - entitiesToProcess = entities.filter((entity) => entity !== "_User"); - } - } - - // Get application info - const appInfo = await show({ configPath, profile: values.profile }); - const authNamespace = appInfo.auth; - - // Initialize operator client (once for all namespaces) - const accessToken = await loadAccessToken({ profile: values.profile }); - const workspaceId = await loadWorkspaceId({ profile: values.profile }); - const operatorClient = await initOperatorClient(accessToken); - - ${generateIdpUserTruncateFunction(hasIdpUser, idpNamespace)} - - // Truncate tables if requested - if (values.truncate) { - const answer = values.yes ? "y" : await promptConfirmation("Are you sure you want to truncate? (y/n): "); - if (answer !== "y") { - console.log(styleText("yellow", "Truncate cancelled.")); - process.exit(0); - } - - console.log(styleText("cyan", "Truncating tables...")); - - try { - if (hasNamespace) { - await truncate({ - configPath, - profile: values.profile, - namespace: values.namespace, - }); - } else if (hasTypes) { - const typesToTruncate = entitiesToProcess.filter((t) => t !== "_User"); - if (typesToTruncate.length > 0) { - await truncate({ - configPath, - profile: values.profile, - types: typesToTruncate, - }); - } else { - console.log(styleText("dim", "No TailorDB types to truncate (only _User was specified).")); - } - } else { - await truncate({ - configPath, - profile: values.profile, - all: true, - }); - } - } catch (error) { - console.error(styleText("red", \`Truncate failed: \${error.message}\`)); - process.exit(1); - } - - ${generateIdpUserTruncateCall(hasIdpUser)} - - console.log(styleText("green", "Truncate completed.")); - } - - console.log(styleText("cyan", "\\nStarting seed data generation...")); - if (skipIdp) { - console.log(styleText("dim", \` Skipping IdP user (_User)\`)); - } - - // Load seed data from JSONL files - const loadSeedData = (dataDir, typeNames) => { - const data = {}; - for (const typeName of typeNames) { - const jsonlPath = join(dataDir, \`\${typeName}.jsonl\`); - try { - const content = readFileSync(jsonlPath, "utf-8").trim(); - if (content) { - data[typeName] = content.split("\\n").map((line) => JSON.parse(line)); - } else { - data[typeName] = []; - } - } catch (error) { - if (error.code === "ENOENT") { - data[typeName] = []; - } else { - throw error; - } - } - } - return data; - }; - - // Topological sort for dependency order - const topologicalSort = (types, deps) => { - const visited = new Set(); - const result = []; - - const visit = (type) => { - if (visited.has(type)) return; - visited.add(type); - const typeDeps = deps[type] || []; - for (const dep of typeDeps) { - if (types.includes(dep)) { - visit(dep); - } - } - result.push(type); - }; - - for (const type of types) { - visit(type); - } - return result; - }; - - // Seed TailorDB types via testExecScript - const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) => { - const dataDir = join(configDir, "data"); - const sortedTypes = topologicalSort(typesToSeed, deps); - const data = loadSeedData(dataDir, sortedTypes); - - // Skip if no data - const typesWithData = sortedTypes.filter((t) => data[t] && data[t].length > 0); - if (typesWithData.length === 0) { - console.log(styleText("dim", \` [\${namespace}] No data to seed\`)); - return { success: true, processed: {} }; - } - - console.log(styleText("cyan", \` [\${namespace}] Seeding \${typesWithData.length} types via Kysely batch insert...\`)); - - // Bundle seed script - const bundled = await bundleSeedScript(namespace, typesWithData); - - // Chunk seed data to fit within gRPC message size limits - const chunks = chunkSeedData({ - data, - order: sortedTypes, - codeByteSize: new TextEncoder().encode(bundled.bundledCode).length, - }); - - if (chunks.length === 0) { - console.log(styleText("dim", \` [\${namespace}] No data to seed\`)); - return { success: true, processed: {} }; - } - - if (chunks.length > 1) { - console.log(styleText("dim", \` Split into \${chunks.length} chunks\`)); - } - - const allProcessed = {}; - let hasError = false; - const allErrors = []; - - for (const chunk of chunks) { - if (chunks.length > 1) { - console.log(styleText("dim", \` Chunk \${chunk.index + 1}/\${chunk.total}: \${chunk.order.join(", ")}\`)); - } - - // Execute seed script for this chunk - const result = await executeScript({ - client: operatorClient, - workspaceId, - name: \`seed-\${namespace}.ts\`, - code: bundled.bundledCode, - arg: { data: chunk.data, order: chunk.order, selfRefTypes }, - invoker: { - namespace: authNamespace, - machineUserName, - }, - }); - - // Parse result and display logs - if (result.logs) { - for (const line of result.logs.split("\\n").filter(Boolean)) { - console.log(styleText("dim", \` \${line}\`)); - } - } - - if (result.success) { - let parsed; - try { - const parsedResult = JSON.parse(result.result || "{}"); - parsed = parsedResult && typeof parsedResult === "object" ? parsedResult : {}; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(styleText("red", \` ✗ Failed to parse seed result: \${message}\`)); - hasError = true; - allErrors.push(message); - continue; - } - - const processed = parsed.processed || {}; - for (const [type, count] of Object.entries(processed)) { - allProcessed[type] = (allProcessed[type] || 0) + count; - console.log(styleText("green", \` ✓ \${type}: \${count} rows inserted\`)); - } - - if (!parsed.success) { - const errors = Array.isArray(parsed.errors) ? parsed.errors : []; - const errorMessage = - errors.length > 0 ? errors.join("\\n ") : "Seed script reported failure"; - console.error(styleText("red", \` ✗ Seed failed:\\n \${errorMessage}\`)); - hasError = true; - allErrors.push(errorMessage); - } - } else { - console.error(styleText("red", \` ✗ Seed failed: \${result.error}\`)); - hasError = true; - allErrors.push(result.error); - } - } - - if (hasError) { - return { success: false, error: allErrors.join("\\n") }; - } - return { success: true, processed: allProcessed }; - }; - - ${generateIdpUserSeedFunction(hasIdpUser, idpNamespace)} - - // Main execution - try { - let allSuccess = true; - - // Determine which namespaces and types to process - const namespacesToProcess = hasNamespace - ? [values.namespace] - : Object.keys(namespaceEntities); - - for (const namespace of namespacesToProcess) { - const nsTypes = namespaceEntities[namespace] || []; - const nsDeps = namespaceDeps[namespace] || {}; - const nsSelfRefTypes = namespaceSelfRefTypes[namespace] || []; - - // Filter types if specific types requested - let typesToSeed = entitiesToProcess - ? nsTypes.filter((t) => entitiesToProcess.includes(t)) - : nsTypes; - - if (typesToSeed.length === 0) continue; - - const result = await seedViaTestExecScript(namespace, typesToSeed, nsDeps, nsSelfRefTypes); - if (!result.success) { - allSuccess = false; - } - } - - ${generateIdpUserSeedCall(hasIdpUser)} - - if (allSuccess) { - console.log(styleText("green", "\\n✓ Seed data generation completed successfully")); - } else { - console.error(styleText("red", "\\n✗ Seed data generation completed with errors")); - process.exit(1); - } - } catch (error) { - console.error(styleText("red", \`\\n✗ Seed data generation failed: \${error.message}\`)); - process.exit(1); - } - - `; -} - /** - * Plugin that generates seed data files with Kysely batch insert and tailor.idp.Client for _User. + * Plugin that generates seed data and schema files consumed by the + * `tailor seed` commands (@tailor-platform/sdk-plugin-seed). * @param options - Plugin options * @param options.distPath - Output directory path for generated seed files * @param options.machineUserName - Default machine user name for authentication @@ -783,23 +66,17 @@ ${namespaceSelfRefEntries} export function seedPlugin(options: SeedPluginOptions): Plugin { return { id: SeedGeneratorID, - description: "Generates seed data files (Kysely batch insert + tailor.idp.Client for _User)", + description: "Generates seed data and schema files for the tailor seed CLI plugin", pluginConfig: options, async onTailorDBReady(ctx: TailorDBReadyContext): Promise { const files: GeneratorResult["files"] = []; - const namespaceConfigs: NamespaceConfig[] = []; // Process IdP user early so we can add reverse FK to the user profile type const idpUser = ctx.auth ? (processIdpUser(ctx.auth) ?? null) : null; - const hasIdpUser = idpUser !== null; const idpUserSyncFKs = resolveIdpUserSyncFKs(ctx.pluginConfig.disableIdpUserSync); for (const ns of ctx.tailordb) { - const types: string[] = []; - const dependencies: Record = {}; - const selfRefTypes: string[] = []; - for (const [typeName, type] of Object.entries(ns.types)) { const source = assertDefined( ns.sourceInfo.get(typeName), @@ -823,12 +100,6 @@ export function seedPlugin(options: SeedPluginOptions): Plugin 0) { - selfRefTypes.push(typeInfo.name); - } - // Generate empty JSONL data file files.push({ path: path.join(ctx.pluginConfig.distPath, typeInfo.dataFile), @@ -884,13 +155,6 @@ export function seedPlugin(options: SeedPluginOptions): Plugin; + /** Types with self-referencing fields, seeded in two passes. */ + selfRefTypes: string[]; +} + +/** + * Build per-namespace seed ordering information from TailorDB namespace data. + * @param tailordb - TailorDB namespaces with their types + * @returns Seed namespace configs, in namespace order + */ +export function buildSeedNamespaceConfigs( + tailordb: TailorDBNamespaceData[], +): SeedNamespaceConfig[] { + return tailordb.map((ns) => { + const types: string[] = []; + const dependencies: Record = {}; + const selfRefTypes: string[] = []; + + for (const type of Object.values(ns.types)) { + const typeInfo = processSeedTypeInfo(type, ns.namespace); + types.push(typeInfo.name); + dependencies[typeInfo.name] = typeInfo.dependencies; + if (typeInfo.selfRefFields.length > 0) { + selfRefTypes.push(typeInfo.name); + } + } + + return { namespace: ns.namespace, types, dependencies, selfRefTypes }; + }); +} diff --git a/packages/sdk/src/plugin/builtin/seed/template-literal.test.ts b/packages/sdk/src/plugin/builtin/seed/template-literal.test.ts deleted file mode 100644 index 69022f7dfc..0000000000 --- a/packages/sdk/src/plugin/builtin/seed/template-literal.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { escapeSeedScriptCodeForTemplateLiteral } from "./template-literal"; - -async function evaluateTemplateLiteralContent(content: string): Promise { - const source = `export default \`${content}\`;`; - const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; - const mod = (await import(/* @vite-ignore */ url)) as { default: string }; - return mod.default; -} - -describe("escapeSeedScriptCodeForTemplateLiteral", () => { - test("round-trips backslashes, backticks, and dollar signs", async () => { - const scriptCode = [ - String.raw`const path = "C:\seed\users.jsonl";`, - "const label = `cost is ${amount} and literal $ value`;", - ].join("\n"); - - await expect( - evaluateTemplateLiteralContent(escapeSeedScriptCodeForTemplateLiteral(scriptCode)), - ).resolves.toBe(scriptCode); - }); -}); diff --git a/packages/sdk/src/plugin/builtin/seed/template-literal.ts b/packages/sdk/src/plugin/builtin/seed/template-literal.ts deleted file mode 100644 index f299a16c03..0000000000 --- a/packages/sdk/src/plugin/builtin/seed/template-literal.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Escape generated script source before embedding it in a template literal. - * @param scriptCode - Generated script source to embed. - * @returns Escaped template literal content. - */ -export function escapeSeedScriptCodeForTemplateLiteral(scriptCode: string): string { - return scriptCode.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$"); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2020bf13ec..c0d7c77be1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@connectrpc/connect-node': specifier: 2.1.2 version: 2.1.2(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.12.1)) + '@tailor-platform/sdk-plugin-seed': + specifier: workspace:^ + version: link:../packages/sdk-plugin-seed '@tailor-platform/sdk-plugin-tailordb-erd': specifier: workspace:^ version: link:../packages/sdk-plugin-tailordb-erd @@ -211,6 +214,9 @@ importers: '@tailor-platform/sdk': specifier: workspace:^ version: link:../../../sdk + '@tailor-platform/sdk-plugin-seed': + specifier: workspace:^ + version: link:../../../sdk-plugin-seed '@types/node': specifier: 24.13.3 version: 24.13.3 @@ -649,6 +655,46 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/sdk-plugin-seed: + dependencies: + chalk: + specifier: 5.6.2 + version: 5.6.2 + pathe: + specifier: 2.0.3 + version: 2.0.3 + pkg-types: + specifier: 2.3.1 + version: 2.3.1 + politty: + specifier: 0.11.2 + version: 0.11.2(@clack/prompts@1.7.0)(@inquirer/prompts@8.5.2(@types/node@24.13.3))(zod@4.4.3) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@tailor-platform/sdk': + specifier: workspace:^ + version: link:../sdk + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.73.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) + oxlint: + specifier: 1.73.0 + version: 1.73.0(oxlint-tsgolint@0.24.0) + tsdown: + specifier: 0.22.5 + version: 0.22.5(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.0)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.0.16(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/sdk-plugin-tailordb-erd: dependencies: chalk: From 0dcc078745e8a43ad10d6987acf65d45e27fad9f Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 17:08:40 +0900 Subject: [PATCH 03/18] refactor(sdk-plugin-seed): rename the shared invoker context to execution context --- packages/sdk-plugin-seed/src/apply.ts | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/sdk-plugin-seed/src/apply.ts b/packages/sdk-plugin-seed/src/apply.ts index dab740ed54..40c84b452e 100644 --- a/packages/sdk-plugin-seed/src/apply.ts +++ b/packages/sdk-plugin-seed/src/apply.ts @@ -22,7 +22,7 @@ import { logger } from "./shared/logger"; import { topologicalSort } from "./topo-sort"; import type { OperatorClient, ScriptExecutionResult } from "@tailor-platform/sdk/cli"; -interface InvokerContext { +interface SeedExecutionContext { operatorClient: OperatorClient; workspaceId: string; authNamespace: string; @@ -30,7 +30,7 @@ interface InvokerContext { } interface SeedNamespaceParams { - invoker: InvokerContext; + execution: SeedExecutionContext; namespace: string; typesToSeed: string[]; dependencies: Record; @@ -39,7 +39,7 @@ interface SeedNamespaceParams { } interface IdpScriptParams { - invoker: InvokerContext; + execution: SeedExecutionContext; scriptCode: string; } @@ -96,7 +96,7 @@ function parseExecutionResult( } async function seedNamespace(params: SeedNamespaceParams): Promise { - const { invoker, namespace, typesToSeed, dependencies, selfRefTypes, dataDir } = params; + const { execution, namespace, typesToSeed, dependencies, selfRefTypes, dataDir } = params; const sortedTypes = topologicalSort(typesToSeed, dependencies); const data = loadSeedData(dataDir, sortedTypes); @@ -134,14 +134,14 @@ async function seedNamespace(params: SeedNamespaceParams): Promise { } const result = await executeScript({ - client: invoker.operatorClient, - workspaceId: invoker.workspaceId, + client: execution.operatorClient, + workspaceId: execution.workspaceId, name: `seed-${namespace}.ts`, code: bundled.bundledCode, arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { - namespace: invoker.authNamespace, - machineUserName: invoker.machineUserName, + namespace: execution.authNamespace, + machineUserName: execution.machineUserName, }, }); @@ -159,7 +159,7 @@ async function seedNamespace(params: SeedNamespaceParams): Promise { } async function seedIdpUser(params: IdpScriptParams & { dataDir: string }): Promise { - const { invoker, scriptCode, dataDir } = params; + const { execution, scriptCode, dataDir } = params; logger.info(" Seeding _User via tailor.idp.Client...", { mode: "plain" }); const rows = loadSeedData(dataDir, ["_User"])._User ?? []; @@ -170,14 +170,14 @@ async function seedIdpUser(params: IdpScriptParams & { dataDir: string }): Promi logger.log(chalk.dim(` Processing ${rows.length} _User records...`)); const result = await executeScript({ - client: invoker.operatorClient, - workspaceId: invoker.workspaceId, + client: execution.operatorClient, + workspaceId: execution.workspaceId, name: "seed-idp-user.ts", code: scriptCode, arg: { users: rows }, invoker: { - namespace: invoker.authNamespace, - machineUserName: invoker.machineUserName, + namespace: execution.authNamespace, + machineUserName: execution.machineUserName, }, }); @@ -194,17 +194,17 @@ async function seedIdpUser(params: IdpScriptParams & { dataDir: string }): Promi } async function truncateIdpUser(params: IdpScriptParams): Promise { - const { invoker, scriptCode } = params; + const { execution, scriptCode } = params; logger.info("Truncating _User via tailor.idp.Client...", { mode: "plain" }); const result = await executeScript({ - client: invoker.operatorClient, - workspaceId: invoker.workspaceId, + client: execution.operatorClient, + workspaceId: execution.workspaceId, name: "truncate-idp-user.ts", code: scriptCode, invoker: { - namespace: invoker.authNamespace, - machineUserName: invoker.machineUserName, + namespace: execution.authNamespace, + machineUserName: execution.machineUserName, }, }); @@ -286,7 +286,7 @@ export const seedApplyCommand = defineAppCommand({ profile: args.profile, workspaceId: args["workspace-id"], }); - const invoker: InvokerContext = { + const execution: SeedExecutionContext = { operatorClient: await initOperatorClient(await loadAccessToken({ profile: args.profile })), workspaceId: await loadWorkspaceId({ workspaceId: args["workspace-id"], @@ -343,7 +343,7 @@ export const seedApplyCommand = defineAppCommand({ (args.types.length === 0 || (selection.entitiesToProcess ?? []).includes("_User")); if (shouldTruncateUser && context.idpUser) { const truncated = await truncateIdpUser({ - invoker, + execution, scriptCode: context.idpUser.truncateScriptCode, }); if (!truncated) { @@ -374,7 +374,7 @@ export const seedApplyCommand = defineAppCommand({ if (typesToSeed.length === 0) continue; const seeded = await seedNamespace({ - invoker, + execution, namespace, typesToSeed, dependencies: nsConfig.dependencies, @@ -392,7 +392,7 @@ export const seedApplyCommand = defineAppCommand({ (!selection.entitiesToProcess || selection.entitiesToProcess.includes("_User")); if (shouldSeedUser && context.idpUser) { const seeded = await seedIdpUser({ - invoker, + execution, scriptCode: context.idpUser.seedScriptCode, dataDir, }); From 690ab71a2e568dae1265c020e46b9d90a1b7d373 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 17:24:04 +0900 Subject: [PATCH 04/18] fix(sdk-plugin-seed): harden the seed apply runtime ported from exec.mjs - treat a script result without success: true as a failure, matching the generated runner's semantics - name the file and line when a JSONL seed line fails to parse - decline truncate confirmation instead of hanging when stdin is not a TTY - validate the seedPlugin distPath option in loadSeedContext - fold the duplicated IdP seed/truncate execution into one helper and move dataDir into the shared execution context - drop the discarded processSeedTypeInfo call from the generate hook - dedupe version resolution in create-sdk's prepare-templates.js and log every rewritten package --- .../create-sdk/scripts/prepare-templates.js | 64 ++++----- packages/sdk-plugin-seed/src/apply.ts | 122 +++++++++--------- packages/sdk-plugin-seed/src/jsonl.test.ts | 6 +- packages/sdk-plugin-seed/src/jsonl.ts | 11 +- .../sdk/src/cli/shared/seed-context.test.ts | 6 + packages/sdk/src/cli/shared/seed-context.ts | 8 +- packages/sdk/src/plugin/builtin/seed/index.ts | 4 +- 7 files changed, 118 insertions(+), 103 deletions(-) diff --git a/packages/create-sdk/scripts/prepare-templates.js b/packages/create-sdk/scripts/prepare-templates.js index 94914a2bdb..15a2c2bc64 100644 --- a/packages/create-sdk/scripts/prepare-templates.js +++ b/packages/create-sdk/scripts/prepare-templates.js @@ -3,45 +3,32 @@ import { readFileSync, writeFileSync, readdirSync, existsSync, copyFileSync } from "node:fs"; import { resolve } from "node:path"; -// Get SDK version or URL from environment variable or package.json -const sdkVersionOrUrl = process.env.TAILOR_TEMPLATE_SDK_VERSION; - -let version; -if (sdkVersionOrUrl) { - // If TAILOR_TEMPLATE_SDK_VERSION is set, use it (can be version string or pkg-pr-new URL) - version = sdkVersionOrUrl; - console.log(`Using SDK version from environment: ${version}`); -} else { - // Otherwise, read version from tailor-sdk's package.json - const tailorSdkPackageJsonPath = resolve(import.meta.dirname, "..", "..", "sdk", "package.json"); - const tailorSdkPackageJson = JSON.parse(readFileSync(tailorSdkPackageJsonPath, "utf-8")); - version = tailorSdkPackageJson.version; - console.log(`Using SDK version from package.json: ${version}`); -} - -// Get seed plugin version or URL the same way (falls back to its package.json) -const seedPluginVersionOrUrl = process.env.TAILOR_TEMPLATE_SEED_PLUGIN_VERSION; - -let seedPluginVersion; -if (seedPluginVersionOrUrl) { - seedPluginVersion = seedPluginVersionOrUrl; - console.log(`Using seed plugin version from environment: ${seedPluginVersion}`); -} else { - const seedPluginPackageJsonPath = resolve( - import.meta.dirname, - "..", - "..", - "sdk-plugin-seed", - "package.json", - ); - const seedPluginPackageJson = JSON.parse(readFileSync(seedPluginPackageJsonPath, "utf-8")); - seedPluginVersion = seedPluginPackageJson.version; - console.log(`Using seed plugin version from package.json: ${seedPluginVersion}`); +// Resolve a package's version or URL from an environment variable (can be a +// version string or a pkg-pr-new URL), falling back to the package.json in +// this repository. +function resolveVersion({ envVar, packageDir, label }) { + const fromEnv = process.env[envVar]; + if (fromEnv) { + console.log(`Using ${label} version from environment: ${fromEnv}`); + return fromEnv; + } + const packageJsonPath = resolve(import.meta.dirname, "..", "..", packageDir, "package.json"); + const { version } = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + console.log(`Using ${label} version from package.json: ${version}`); + return version; } const packageVersions = { - "@tailor-platform/sdk": version, - "@tailor-platform/sdk-plugin-seed": seedPluginVersion, + "@tailor-platform/sdk": resolveVersion({ + envVar: "TAILOR_TEMPLATE_SDK_VERSION", + packageDir: "sdk", + label: "SDK", + }), + "@tailor-platform/sdk-plugin-seed": resolveVersion({ + envVar: "TAILOR_TEMPLATE_SEED_PLUGIN_VERSION", + packageDir: "sdk-plugin-seed", + label: "seed plugin", + }), }; // Update versions in each template's package.json @@ -54,17 +41,20 @@ for (const template of templates) { if (!existsSync(packageJsonPath)) continue; const content = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + const updated = []; for (const [packageName, packageVersion] of Object.entries(packageVersions)) { if (content.dependencies?.[packageName]) { content.dependencies[packageName] = packageVersion; + updated.push(`${packageName}@${packageVersion}`); } if (content.devDependencies?.[packageName]) { content.devDependencies[packageName] = packageVersion; + updated.push(`${packageName}@${packageVersion}`); } } writeFileSync(packageJsonPath, JSON.stringify(content, null, 2) + "\n"); - console.log(`Updated ${template}/package.json to use SDK: ${version}`); + console.log(`Updated ${template}/package.json to use ${updated.join(", ")}`); } // Copy .gitignore to __dot__gitignore diff --git a/packages/sdk-plugin-seed/src/apply.ts b/packages/sdk-plugin-seed/src/apply.ts index 40c84b452e..a423200e50 100644 --- a/packages/sdk-plugin-seed/src/apply.ts +++ b/packages/sdk-plugin-seed/src/apply.ts @@ -20,13 +20,14 @@ import { deploymentArgs } from "./shared/args"; import { defineAppCommand } from "./shared/command"; import { logger } from "./shared/logger"; import { topologicalSort } from "./topo-sort"; -import type { OperatorClient, ScriptExecutionResult } from "@tailor-platform/sdk/cli"; +import type { OperatorClient, ScriptExecutionResult, SeedData } from "@tailor-platform/sdk/cli"; interface SeedExecutionContext { operatorClient: OperatorClient; workspaceId: string; authNamespace: string; machineUserName: string; + dataDir: string; } interface SeedNamespaceParams { @@ -35,15 +36,13 @@ interface SeedNamespaceParams { typesToSeed: string[]; dependencies: Record; selfRefTypes: string[]; - dataDir: string; -} - -interface IdpScriptParams { - execution: SeedExecutionContext; - scriptCode: string; } function promptConfirmation(question: string): Promise { + if (!process.stdin.isTTY) { + logger.warn("Interactive confirmation is not available; pass --yes to proceed."); + return Promise.resolve(false); + } const rl = createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(chalk.yellow(question), (answer) => { @@ -83,7 +82,7 @@ function parseExecutionResult( return { success: false, parsed: {}, errors: [`Failed to parse result: ${message}`] }; } - if (parsed.success === false) { + if (!parsed.success) { const errors = Array.isArray(parsed.errors) ? (parsed.errors as string[]) : []; return { success: false, @@ -96,9 +95,9 @@ function parseExecutionResult( } async function seedNamespace(params: SeedNamespaceParams): Promise { - const { execution, namespace, typesToSeed, dependencies, selfRefTypes, dataDir } = params; + const { execution, namespace, typesToSeed, dependencies, selfRefTypes } = params; const sortedTypes = topologicalSort(typesToSeed, dependencies); - const data = loadSeedData(dataDir, sortedTypes); + const data = loadSeedData(execution.dataDir, sortedTypes); const typesWithData = sortedTypes.filter((type) => data[type] && data[type].length > 0); if (typesWithData.length === 0) { @@ -158,66 +157,81 @@ async function seedNamespace(params: SeedNamespaceParams): Promise { return success; } -async function seedIdpUser(params: IdpScriptParams & { dataDir: string }): Promise { - const { execution, scriptCode, dataDir } = params; - logger.info(" Seeding _User via tailor.idp.Client...", { mode: "plain" }); +interface IdpScriptRun { + execution: SeedExecutionContext; + scriptCode: string; + scriptName: string; + arg?: { users: SeedData[string] }; + indent: string; + reportSuccess: (parsed: Record) => void; +} - const rows = loadSeedData(dataDir, ["_User"])._User ?? []; - if (rows.length === 0) { - logger.log(chalk.dim(" No _User data to seed")); - return true; - } - logger.log(chalk.dim(` Processing ${rows.length} _User records...`)); +async function runIdpScript(params: IdpScriptRun): Promise { + const { execution, scriptCode, scriptName, arg, indent, reportSuccess } = params; const result = await executeScript({ client: execution.operatorClient, workspaceId: execution.workspaceId, - name: "seed-idp-user.ts", + name: scriptName, code: scriptCode, - arg: { users: rows }, + ...(arg ? { arg } : {}), invoker: { namespace: execution.authNamespace, machineUserName: execution.machineUserName, }, }); - const { success, parsed, errors } = parseExecutionResult(result, " "); - if (typeof parsed.processed === "number") { - logger.log(chalk.green(` ✓ _User: ${parsed.processed} rows processed`)); - } + const { success, parsed, errors } = parseExecutionResult(result, indent); + reportSuccess(parsed); if (!success) { for (const error of errors) { - logger.error(` ${error}`, { mode: "plain" }); + logger.error(`${indent}${error}`, { mode: "plain" }); } } return success; } -async function truncateIdpUser(params: IdpScriptParams): Promise { - const { execution, scriptCode } = params; - logger.info("Truncating _User via tailor.idp.Client...", { mode: "plain" }); +async function seedIdpUser(execution: SeedExecutionContext, scriptCode: string): Promise { + logger.info(" Seeding _User via tailor.idp.Client...", { mode: "plain" }); - const result = await executeScript({ - client: execution.operatorClient, - workspaceId: execution.workspaceId, - name: "truncate-idp-user.ts", - code: scriptCode, - invoker: { - namespace: execution.authNamespace, - machineUserName: execution.machineUserName, + const rows = loadSeedData(execution.dataDir, ["_User"])._User ?? []; + if (rows.length === 0) { + logger.log(chalk.dim(" No _User data to seed")); + return true; + } + logger.log(chalk.dim(` Processing ${rows.length} _User records...`)); + + return await runIdpScript({ + execution, + scriptCode, + scriptName: "seed-idp-user.ts", + arg: { users: rows }, + indent: " ", + reportSuccess: (parsed) => { + if (typeof parsed.processed === "number") { + logger.log(chalk.green(` ✓ _User: ${parsed.processed} rows processed`)); + } }, }); +} - const { success, parsed, errors } = parseExecutionResult(result, " "); - if (typeof parsed.deleted === "number") { - logger.log(chalk.green(` ✓ _User: ${parsed.deleted} users deleted`)); - } - if (!success) { - for (const error of errors) { - logger.error(` ${error}`, { mode: "plain" }); - } - } - return success; +async function truncateIdpUser( + execution: SeedExecutionContext, + scriptCode: string, +): Promise { + logger.info("Truncating _User via tailor.idp.Client...", { mode: "plain" }); + + return await runIdpScript({ + execution, + scriptCode, + scriptName: "truncate-idp-user.ts", + indent: " ", + reportSuccess: (parsed) => { + if (typeof parsed.deleted === "number") { + logger.log(chalk.green(` ✓ _User: ${parsed.deleted} users deleted`)); + } + }, + }); } export const seedApplyCommand = defineAppCommand({ @@ -294,8 +308,8 @@ export const seedApplyCommand = defineAppCommand({ }), authNamespace: appInfo.auth, machineUserName, + dataDir: path.join(context.distPath, "data"), }; - const dataDir = path.join(context.distPath, "data"); if (args.truncate) { const confirmed = @@ -342,10 +356,7 @@ export const seedApplyCommand = defineAppCommand({ !args.namespace && (args.types.length === 0 || (selection.entitiesToProcess ?? []).includes("_User")); if (shouldTruncateUser && context.idpUser) { - const truncated = await truncateIdpUser({ - execution, - scriptCode: context.idpUser.truncateScriptCode, - }); + const truncated = await truncateIdpUser(execution, context.idpUser.truncateScriptCode); if (!truncated) { throw new Error("IdP user truncation failed."); } @@ -379,7 +390,6 @@ export const seedApplyCommand = defineAppCommand({ typesToSeed, dependencies: nsConfig.dependencies, selfRefTypes: nsConfig.selfRefTypes, - dataDir, }); if (!seeded) { allSuccess = false; @@ -391,11 +401,7 @@ export const seedApplyCommand = defineAppCommand({ !args["skip-idp"] && (!selection.entitiesToProcess || selection.entitiesToProcess.includes("_User")); if (shouldSeedUser && context.idpUser) { - const seeded = await seedIdpUser({ - execution, - scriptCode: context.idpUser.seedScriptCode, - dataDir, - }); + const seeded = await seedIdpUser(execution, context.idpUser.seedScriptCode); if (!seeded) { allSuccess = false; } diff --git a/packages/sdk-plugin-seed/src/jsonl.test.ts b/packages/sdk-plugin-seed/src/jsonl.test.ts index 76a48b9c68..0548f93189 100644 --- a/packages/sdk-plugin-seed/src/jsonl.test.ts +++ b/packages/sdk-plugin-seed/src/jsonl.test.ts @@ -32,8 +32,8 @@ describe("loadSeedData", () => { expect(loadSeedData(dir, ["Empty", "Missing"])).toEqual({ Empty: [], Missing: [] }); }); - test("throws on malformed JSON lines", () => { - const dir = makeDataDir({ "Bad.jsonl": "not-json\n" }); - expect(() => loadSeedData(dir, ["Bad"])).toThrow(SyntaxError); + test("names the file and line for malformed JSON lines", () => { + const dir = makeDataDir({ "Bad.jsonl": '{"ok":true}\nnot-json\n' }); + expect(() => loadSeedData(dir, ["Bad"])).toThrow(/Bad\.jsonl at line 2/); }); }); diff --git a/packages/sdk-plugin-seed/src/jsonl.ts b/packages/sdk-plugin-seed/src/jsonl.ts index 0b8b00ad7c..41b70b906e 100644 --- a/packages/sdk-plugin-seed/src/jsonl.ts +++ b/packages/sdk-plugin-seed/src/jsonl.ts @@ -24,7 +24,16 @@ export function loadSeedData(dataDir: string, typeNames: string[]): SeedData { throw error; } data[typeName] = content - ? content.split("\n").map((line) => JSON.parse(line) as SeedData[string][number]) + ? content.split("\n").map((line, index) => { + try { + return JSON.parse(line) as SeedData[string][number]; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid JSON in ${jsonlPath} at line ${index + 1}: ${message}`, { + cause: error, + }); + } + }) : []; } return data; diff --git a/packages/sdk/src/cli/shared/seed-context.test.ts b/packages/sdk/src/cli/shared/seed-context.test.ts index abedf93b31..fb70c09db9 100644 --- a/packages/sdk/src/cli/shared/seed-context.test.ts +++ b/packages/sdk/src/cli/shared/seed-context.test.ts @@ -52,6 +52,12 @@ describe("loadSeedContext", () => { ); }); + test("throws when seedPlugin has no distPath", async () => { + mockLoadResult({ plugins: [seedPluginInstance({})] }); + + await expect(loadSeedContext()).rejects.toThrow(/has no distPath option/); + }); + test("resolves a relative distPath against the config directory", async () => { mockLoadResult({ plugins: [seedPluginInstance({ distPath: "./seed" })] }); diff --git a/packages/sdk/src/cli/shared/seed-context.ts b/packages/sdk/src/cli/shared/seed-context.ts index 378e9507b8..d396e4d52f 100644 --- a/packages/sdk/src/cli/shared/seed-context.ts +++ b/packages/sdk/src/cli/shared/seed-context.ts @@ -72,7 +72,13 @@ export async function loadSeedContext(options: LoadSeedContextOptions = {}): Pro 'Add seedPlugin({ distPath: "./seed" }) from "@tailor-platform/sdk/plugin/seed" to definePlugins().', ); } - const pluginOptions = seedPlugin.pluginConfig as SeedPluginOptions; + const pluginOptions = seedPlugin.pluginConfig as SeedPluginOptions | undefined; + if (typeof pluginOptions?.distPath !== "string" || pluginOptions.distPath === "") { + throw new Error( + `seedPlugin in ${config.path} has no distPath option. ` + + 'Pass seedPlugin({ distPath: "./seed" }) so seed data has a location.', + ); + } const authInput = getAuthInput(application); const idpUserMeta = authInput ? processIdpUser(authInput) : undefined; diff --git a/packages/sdk/src/plugin/builtin/seed/index.ts b/packages/sdk/src/plugin/builtin/seed/index.ts index 5b3c472259..aeac0cf305 100644 --- a/packages/sdk/src/plugin/builtin/seed/index.ts +++ b/packages/sdk/src/plugin/builtin/seed/index.ts @@ -7,7 +7,6 @@ import { generateLinesDbSchemaFileWithPluginAPI, type PluginSchemaParams, } from "./lines-db-processor"; -import { processSeedTypeInfo } from "./seed-type-processor"; import type { Plugin, GeneratorResult, TailorDBReadyContext } from "#/plugin/types"; /** Unique identifier for the seed generator plugin. */ @@ -82,7 +81,6 @@ export function seedPlugin(options: SeedPluginOptions): Plugin Date: Fri, 17 Jul 2026 17:27:46 +0900 Subject: [PATCH 05/18] fix(create-sdk): log template updates only when a package was rewritten --- packages/create-sdk/scripts/prepare-templates.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/create-sdk/scripts/prepare-templates.js b/packages/create-sdk/scripts/prepare-templates.js index 15a2c2bc64..e7a45bde07 100644 --- a/packages/create-sdk/scripts/prepare-templates.js +++ b/packages/create-sdk/scripts/prepare-templates.js @@ -54,7 +54,9 @@ for (const template of templates) { } writeFileSync(packageJsonPath, JSON.stringify(content, null, 2) + "\n"); - console.log(`Updated ${template}/package.json to use ${updated.join(", ")}`); + if (updated.length > 0) { + console.log(`Updated ${template}/package.json to use ${updated.join(", ")}`); + } } // Copy .gitignore to __dot__gitignore From 56fb52d4aa281baaa69d20e6ffe58421c8621d16 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 21:02:07 +0900 Subject: [PATCH 06/18] fix(sdk-plugin-seed): resolve IdP context and distPath like generate, emit --json output - resolve auth namespaces in loadSeedContext before reading userProfile; without it the IdP _User context was always null at run time - resolve a relative distPath against the working directory, the same base tailor generate writes it to - honor --json: apply emits an aggregated { success, processed } summary and validate emits { valid, path } --- packages/sdk-plugin-seed/src/apply.ts | 60 +++++++++++++------ packages/sdk-plugin-seed/src/validate.ts | 3 + .../sdk/src/cli/shared/seed-context.test.ts | 41 ++++++++----- packages/sdk/src/cli/shared/seed-context.ts | 8 ++- 4 files changed, 78 insertions(+), 34 deletions(-) diff --git a/packages/sdk-plugin-seed/src/apply.ts b/packages/sdk-plugin-seed/src/apply.ts index a423200e50..13ec155bfa 100644 --- a/packages/sdk-plugin-seed/src/apply.ts +++ b/packages/sdk-plugin-seed/src/apply.ts @@ -94,15 +94,21 @@ function parseExecutionResult( return { success: true, parsed, errors: [] }; } -async function seedNamespace(params: SeedNamespaceParams): Promise { +interface SeedResult { + success: boolean; + processed: Record; +} + +async function seedNamespace(params: SeedNamespaceParams): Promise { const { execution, namespace, typesToSeed, dependencies, selfRefTypes } = params; const sortedTypes = topologicalSort(typesToSeed, dependencies); const data = loadSeedData(execution.dataDir, sortedTypes); + const processedTotals: Record = {}; const typesWithData = sortedTypes.filter((type) => data[type] && data[type].length > 0); if (typesWithData.length === 0) { logger.log(chalk.dim(` [${namespace}] No data to seed`)); - return true; + return { success: true, processed: processedTotals }; } logger.info(` [${namespace}] Seeding ${typesWithData.length} types via Kysely batch insert...`, { @@ -118,7 +124,7 @@ async function seedNamespace(params: SeedNamespaceParams): Promise { if (chunks.length === 0) { logger.log(chalk.dim(` [${namespace}] No data to seed`)); - return true; + return { success: true, processed: processedTotals }; } if (chunks.length > 1) { logger.log(chalk.dim(` Split into ${chunks.length} chunks`)); @@ -147,6 +153,7 @@ async function seedNamespace(params: SeedNamespaceParams): Promise { const { success: chunkSuccess, parsed, errors } = parseExecutionResult(result, " "); const processed = (parsed.processed ?? {}) as Record; for (const [type, count] of Object.entries(processed)) { + processedTotals[type] = (processedTotals[type] ?? 0) + count; logger.log(chalk.green(` ✓ ${type}: ${count} rows inserted`)); } if (!chunkSuccess) { @@ -154,7 +161,7 @@ async function seedNamespace(params: SeedNamespaceParams): Promise { success = false; } } - return success; + return { success, processed: processedTotals }; } interface IdpScriptRun { @@ -166,7 +173,9 @@ interface IdpScriptRun { reportSuccess: (parsed: Record) => void; } -async function runIdpScript(params: IdpScriptRun): Promise { +async function runIdpScript( + params: IdpScriptRun, +): Promise<{ success: boolean; parsed: Record }> { const { execution, scriptCode, scriptName, arg, indent, reportSuccess } = params; const result = await executeScript({ @@ -188,31 +197,35 @@ async function runIdpScript(params: IdpScriptRun): Promise { logger.error(`${indent}${error}`, { mode: "plain" }); } } - return success; + return { success, parsed }; } -async function seedIdpUser(execution: SeedExecutionContext, scriptCode: string): Promise { +async function seedIdpUser( + execution: SeedExecutionContext, + scriptCode: string, +): Promise<{ success: boolean; processed: number }> { logger.info(" Seeding _User via tailor.idp.Client...", { mode: "plain" }); const rows = loadSeedData(execution.dataDir, ["_User"])._User ?? []; if (rows.length === 0) { logger.log(chalk.dim(" No _User data to seed")); - return true; + return { success: true, processed: 0 }; } logger.log(chalk.dim(` Processing ${rows.length} _User records...`)); - return await runIdpScript({ + const { success, parsed } = await runIdpScript({ execution, scriptCode, scriptName: "seed-idp-user.ts", arg: { users: rows }, indent: " ", - reportSuccess: (parsed) => { - if (typeof parsed.processed === "number") { - logger.log(chalk.green(` ✓ _User: ${parsed.processed} rows processed`)); + reportSuccess: (result) => { + if (typeof result.processed === "number") { + logger.log(chalk.green(` ✓ _User: ${result.processed} rows processed`)); } }, }); + return { success, processed: typeof parsed.processed === "number" ? parsed.processed : 0 }; } async function truncateIdpUser( @@ -221,17 +234,18 @@ async function truncateIdpUser( ): Promise { logger.info("Truncating _User via tailor.idp.Client...", { mode: "plain" }); - return await runIdpScript({ + const { success } = await runIdpScript({ execution, scriptCode, scriptName: "truncate-idp-user.ts", indent: " ", - reportSuccess: (parsed) => { - if (typeof parsed.deleted === "number") { - logger.log(chalk.green(` ✓ _User: ${parsed.deleted} users deleted`)); + reportSuccess: (result) => { + if (typeof result.deleted === "number") { + logger.log(chalk.green(` ✓ _User: ${result.deleted} users deleted`)); } }, }); + return success; } export const seedApplyCommand = defineAppCommand({ @@ -371,6 +385,7 @@ export const seedApplyCommand = defineAppCommand({ } let allSuccess = true; + const allProcessed: Record = {}; const namespacesToProcess = args.namespace ? [args.namespace] @@ -391,7 +406,10 @@ export const seedApplyCommand = defineAppCommand({ dependencies: nsConfig.dependencies, selfRefTypes: nsConfig.selfRefTypes, }); - if (!seeded) { + for (const [type, count] of Object.entries(seeded.processed)) { + allProcessed[type] = (allProcessed[type] ?? 0) + count; + } + if (!seeded.success) { allSuccess = false; } } @@ -402,12 +420,18 @@ export const seedApplyCommand = defineAppCommand({ (!selection.entitiesToProcess || selection.entitiesToProcess.includes("_User")); if (shouldSeedUser && context.idpUser) { const seeded = await seedIdpUser(execution, context.idpUser.seedScriptCode); - if (!seeded) { + if (seeded.processed > 0) { + allProcessed._User = seeded.processed; + } + if (!seeded.success) { allSuccess = false; } } logger.newline(); + if (args.json) { + logger.out({ success: allSuccess, processed: allProcessed }); + } if (!allSuccess) { throw new Error("Seed data generation completed with errors"); } diff --git a/packages/sdk-plugin-seed/src/validate.ts b/packages/sdk-plugin-seed/src/validate.ts index 55114fabd9..a2ce9f3101 100644 --- a/packages/sdk-plugin-seed/src/validate.ts +++ b/packages/sdk-plugin-seed/src/validate.ts @@ -33,6 +33,9 @@ export const seedValidateCommand = defineAppCommand({ if (result.output) { logger.log(result.output); } + if (args.json) { + logger.out({ valid: result.valid, path: targetPath }); + } if (!result.valid) { throw new Error(result.error); } diff --git a/packages/sdk/src/cli/shared/seed-context.test.ts b/packages/sdk/src/cli/shared/seed-context.test.ts index fb70c09db9..2a75f5b2cf 100644 --- a/packages/sdk/src/cli/shared/seed-context.test.ts +++ b/packages/sdk/src/cli/shared/seed-context.test.ts @@ -1,3 +1,4 @@ +import * as path from "pathe"; import { describe, expect, test, vi } from "vitest"; import { loadSeedContext } from "./seed-context"; import type { Application } from "#/cli/services/application"; @@ -43,6 +44,19 @@ function seedPluginInstance(pluginConfig: object): Plugin { return { id: "@tailor-platform/seed", pluginConfig } as Plugin; } +function fakeAuthService(config: object, userProfileAfterResolve?: object) { + let userProfile: object | undefined; + return { + config, + get userProfile() { + return userProfile; + }, + resolveNamespaces: async () => { + userProfile = userProfileAfterResolve; + }, + } as unknown as Application["authService"]; +} + describe("loadSeedContext", () => { test("throws an actionable error when seedPlugin is not configured", async () => { mockLoadResult({ plugins: [{ id: "@tailor-platform/other" } as Plugin] }); @@ -58,11 +72,11 @@ describe("loadSeedContext", () => { await expect(loadSeedContext()).rejects.toThrow(/has no distPath option/); }); - test("resolves a relative distPath against the config directory", async () => { + test("resolves a relative distPath against the working directory", async () => { mockLoadResult({ plugins: [seedPluginInstance({ distPath: "./seed" })] }); const context = await loadSeedContext(); - expect(context.distPath).toBe("/proj/seed"); + expect(context.distPath).toBe(path.resolve("./seed")); expect(context.machineUserName).toBeUndefined(); expect(context.idpUser).toBeNull(); }); @@ -103,18 +117,20 @@ describe("loadSeedContext", () => { mockLoadResult({ plugins: [seedPluginInstance({ distPath: "./seed" })], application: { - authService: { - config: { + // userProfile only becomes available after resolveNamespaces() runs, + // mirroring the real auth service lifecycle. + authService: fakeAuthService( + { name: "main-auth", machineUsers: {}, idProvider: { kind: "BuiltInIdP", namespace: "main-idp" }, }, - userProfile: { + { type: { name: "Staff" }, namespace: "main-db", usernameField: "email", }, - } as unknown as Application["authService"], + ), }, }); @@ -129,14 +145,11 @@ describe("loadSeedContext", () => { mockLoadResult({ plugins: [seedPluginInstance({ distPath: "./seed" })], application: { - authService: { - config: { - name: "main-auth", - machineUsers: {}, - idProvider: { kind: "OIDC", namespace: "ext-idp" }, - }, - userProfile: undefined, - } as unknown as Application["authService"], + authService: fakeAuthService({ + name: "main-auth", + machineUsers: {}, + idProvider: { kind: "OIDC", namespace: "ext-idp" }, + }), }, }); diff --git a/packages/sdk/src/cli/shared/seed-context.ts b/packages/sdk/src/cli/shared/seed-context.ts index d396e4d52f..635c45dfed 100644 --- a/packages/sdk/src/cli/shared/seed-context.ts +++ b/packages/sdk/src/cli/shared/seed-context.ts @@ -56,7 +56,8 @@ export interface LoadSeedContextOptions { /** * Load the seed context from the local config. Requires `seedPlugin` to be * configured in the config's plugins. A relative `distPath` in the seedPlugin - * options is resolved against the config file's directory. + * options is resolved against the current working directory — the same base + * `tailor generate` writes it to. * @param options - Seed context loading options. * @returns The seed context computed from the local config. */ @@ -80,6 +81,9 @@ export async function loadSeedContext(options: LoadSeedContextOptions = {}): Pro ); } + // userProfile is only populated once auth namespaces are resolved (the + // generate flow does the same after loading TailorDB namespaces). + await application.authService?.resolveNamespaces(); const authInput = getAuthInput(application); const idpUserMeta = authInput ? processIdpUser(authInput) : undefined; const idpUser: SeedIdpUserContext | null = idpUserMeta @@ -92,7 +96,7 @@ export async function loadSeedContext(options: LoadSeedContextOptions = {}): Pro return { config, - distPath: path.resolve(path.dirname(config.path), pluginOptions.distPath), + distPath: path.resolve(pluginOptions.distPath), machineUserName: pluginOptions.machineUserName, namespaces: buildSeedNamespaceConfigs(namespaces), idpUser, From ce89878795435449eee10be0919e6259fcac5b26 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 21:23:42 +0900 Subject: [PATCH 07/18] fix(sdk): tighten seed CLI edge cases from review - reject duplicate cross-namespace type names in loadSeedContext, matching generation and deploy - report a cancelled truncate as a failure (exit 1) and emit the --json cancel summary - accept -v for --verbose, matching the generated seed runner's validate - also flag exec.mjs references inside source strings in the migration registry entry --- packages/sdk-codemod/src/registry.ts | 1 + packages/sdk-plugin-seed/src/apply.ts | 4 ++++ packages/sdk-plugin-seed/src/shared/args.ts | 2 ++ .../sdk/src/cli/shared/seed-context.test.ts | 23 ++++++++++++++++++- packages/sdk/src/cli/shared/seed-context.ts | 5 ++++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/sdk-codemod/src/registry.ts b/packages/sdk-codemod/src/registry.ts index 4f9fe2e70e..c36e7a861b 100644 --- a/packages/sdk-codemod/src/registry.ts +++ b/packages/sdk-codemod/src/registry.ts @@ -1212,6 +1212,7 @@ export const allCodemods: CodemodPackage[] = [ // No scriptPath: this is a codemod-less ("manual") migration. filePatterns: ["**/package.json", "**/*.{sh,yml,yaml,md,mjs,ts}"], suspiciousPatterns: ["exec.mjs"], + sourceStringSuspiciousPatterns: ["exec.mjs"], examples: [ { before: '"seed": "node ./seed/exec.mjs",\n"seed:validate": "node ./seed/exec.mjs validate"', diff --git a/packages/sdk-plugin-seed/src/apply.ts b/packages/sdk-plugin-seed/src/apply.ts index 13ec155bfa..c0836ce196 100644 --- a/packages/sdk-plugin-seed/src/apply.ts +++ b/packages/sdk-plugin-seed/src/apply.ts @@ -330,6 +330,10 @@ export const seedApplyCommand = defineAppCommand({ args.yes || (await promptConfirmation("Are you sure you want to truncate? (y/n): ")); if (!confirmed) { logger.warn("Truncate cancelled."); + if (args.json) { + logger.out({ success: false, cancelled: true, processed: {} }); + } + process.exitCode = 1; return; } diff --git a/packages/sdk-plugin-seed/src/shared/args.ts b/packages/sdk-plugin-seed/src/shared/args.ts index 5ed9b5dee0..0e8a47a271 100644 --- a/packages/sdk-plugin-seed/src/shared/args.ts +++ b/packages/sdk-plugin-seed/src/shared/args.ts @@ -64,6 +64,8 @@ export const commonArgs = { }, }), verbose: arg(z.boolean().default(false), { + // -v matches the generated seed runner this plugin replaces. + alias: "v", description: "Enable verbose logging", effect: (value) => { logger.verbose = value; diff --git a/packages/sdk/src/cli/shared/seed-context.test.ts b/packages/sdk/src/cli/shared/seed-context.test.ts index 2a75f5b2cf..e975d2ac67 100644 --- a/packages/sdk/src/cli/shared/seed-context.test.ts +++ b/packages/sdk/src/cli/shared/seed-context.test.ts @@ -35,11 +35,21 @@ function mockLoadResult(result: FakeLoadResult): void { loadApplicationNamespaces.mockResolvedValue({ config: { path: "/proj/tailor.config.ts" }, plugins: result.plugins ?? [], - application: result.application ?? {}, + application: { tailorDBServices: [], ...result.application }, namespaces: result.namespaces ?? [], } as LoadedApplicationNamespaces); } +function fakeService(namespace: string, typeNames: string[]) { + return { + namespace, + types: Object.fromEntries(typeNames.map((name) => [name, fakeType(name, {})])), + typeSourceInfo: Object.fromEntries( + typeNames.map((name) => [name, { filePath: `${namespace}/${name}.ts`, exportName: name }]), + ), + }; +} + function seedPluginInstance(pluginConfig: object): Plugin { return { id: "@tailor-platform/seed", pluginConfig } as Plugin; } @@ -66,6 +76,17 @@ describe("loadSeedContext", () => { ); }); + test("rejects duplicate type names across namespaces", async () => { + mockLoadResult({ + plugins: [seedPluginInstance({ distPath: "./seed" })], + application: { + tailorDBServices: [fakeService("main-db", ["User"]), fakeService("sub-db", ["User"])], + } as unknown as Partial, + }); + + await expect(loadSeedContext()).rejects.toThrow(/User/); + }); + test("throws when seedPlugin has no distPath", async () => { mockLoadResult({ plugins: [seedPluginInstance({})] }); diff --git a/packages/sdk/src/cli/shared/seed-context.ts b/packages/sdk/src/cli/shared/seed-context.ts index 635c45dfed..f4af011984 100644 --- a/packages/sdk/src/cli/shared/seed-context.ts +++ b/packages/sdk/src/cli/shared/seed-context.ts @@ -1,4 +1,5 @@ import * as path from "pathe"; +import { assertUniqueLocalTailorDBTypeNames } from "#/cli/services/tailordb/type-name-validation"; import { generateIdpSeedScriptCode, generateIdpTruncateScriptCode, @@ -66,6 +67,10 @@ export async function loadSeedContext(options: LoadSeedContextOptions = {}): Pro configPath: options.configPath, }); + // Seed files and type filters identify types by bare name, so enforce the + // same cross-namespace uniqueness that generation and deploy enforce. + assertUniqueLocalTailorDBTypeNames({ tailorDBServices: application.tailorDBServices }); + const seedPlugin = plugins.find((plugin) => plugin.id === SeedGeneratorID); if (!seedPlugin) { throw new Error( From febb9448752002e50ecec93647e341644aad8629 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 21:38:30 +0900 Subject: [PATCH 08/18] test(cli): cover the seed plugin install hint --- packages/sdk/src/cli/shared/plugin.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/sdk/src/cli/shared/plugin.test.ts b/packages/sdk/src/cli/shared/plugin.test.ts index 7b9889e66a..b09069e734 100644 --- a/packages/sdk/src/cli/shared/plugin.test.ts +++ b/packages/sdk/src/cli/shared/plugin.test.ts @@ -441,6 +441,26 @@ describe("dispatchPluginWithInstallHint", () => { ); }); + test("prints an install hint for the top-level seed plugin", async () => { + const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); + const infoSpy = vi.spyOn(logger, "info").mockImplementation(() => {}); + + const code = await dispatchPluginWithInstallHint({ + commandPath: [], + name: "seed", + args: ["apply"], + cliName: CLI, + }); + + expect(code).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + `"${CLI} seed" is provided by the @tailor-platform/sdk-plugin-seed CLI plugin, which is not installed.`, + ); + expect(infoSpy).toHaveBeenCalledWith( + "Install it with: npm install -D @tailor-platform/sdk-plugin-seed", + ); + }); + test("returns undefined for an unknown subcommand with no known package", async () => { const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); const infoSpy = vi.spyOn(logger, "info").mockImplementation(() => {}); From f4ae5360ee69a6dd74ab8f0a5dfb5bbf321213fc Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 21:51:31 +0900 Subject: [PATCH 09/18] fix(sdk-plugin-seed): exit non-zero when a truncate is declined politty's runMain resets process.exitCode on a successful run, so the cancelled path must throw instead of setting the exit code directly. --- packages/sdk-plugin-seed/src/apply.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sdk-plugin-seed/src/apply.ts b/packages/sdk-plugin-seed/src/apply.ts index c0836ce196..d0cc6c1603 100644 --- a/packages/sdk-plugin-seed/src/apply.ts +++ b/packages/sdk-plugin-seed/src/apply.ts @@ -329,12 +329,10 @@ export const seedApplyCommand = defineAppCommand({ const confirmed = args.yes || (await promptConfirmation("Are you sure you want to truncate? (y/n): ")); if (!confirmed) { - logger.warn("Truncate cancelled."); if (args.json) { logger.out({ success: false, cancelled: true, processed: {} }); } - process.exitCode = 1; - return; + throw new Error("Truncate cancelled."); } logger.info("Truncating tables..."); From 81b6b0c4db03947d3aa51012ea5db61c06385a34 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 21:56:55 +0900 Subject: [PATCH 10/18] ci: build the seed CLI plugin in install-deps so its bin resolves The example seed scripts run `tailor seed` in the deploy e2e workflow; without a built dist/index.js pnpm never links the tailor-seed bin and plugin dispatch reports the plugin as not installed. --- .github/actions/install-deps/action.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/install-deps/action.yml b/.github/actions/install-deps/action.yml index 89d4aced68..0b7ffd35cb 100644 --- a/.github/actions/install-deps/action.yml +++ b/.github/actions/install-deps/action.yml @@ -32,12 +32,12 @@ runs: run: pnpm install shell: bash - # Build SDK so that the bin entry points to a real dist file - - name: Build SDK package - run: pnpm --filter @tailor-platform/sdk run build + # Build the SDK and the seed CLI plugin so their bin entries point to real dist files + - name: Build SDK packages + run: pnpm --filter @tailor-platform/sdk --filter @tailor-platform/sdk-plugin-seed run build shell: bash - # Re-link workspace bin entries after their SDK target has been built. + # Re-link workspace bin entries after their targets have been built. - name: Re-link SDK bin entries after build - run: pnpm --filter '...@tailor-platform/sdk' --filter '!@tailor-platform/sdk' rebuild --pending + run: pnpm --filter '...@tailor-platform/sdk' --filter '...@tailor-platform/sdk-plugin-seed' --filter '!@tailor-platform/sdk' --filter '!@tailor-platform/sdk-plugin-seed' rebuild --pending shell: bash From 5342386ebb8ba361ca970e0b4d6369c4e31c3227 Mon Sep 17 00:00:00 2001 From: dqn Date: Mon, 20 Jul 2026 10:07:54 +0900 Subject: [PATCH 11/18] fix(ci): align seed plugin preview URLs --- .github/workflows/pkg-pr-new.yml | 41 ++++++++++++++++++++++------- scripts/pkg-pr-new-workflow.test.js | 27 +++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 scripts/pkg-pr-new-workflow.test.js diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 17d24a2844..4147a3f844 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -28,6 +28,7 @@ jobs: outputs: sdk_url: ${{ steps.urls.outputs.sdk_url }} create_sdk_url: ${{ steps.urls.outputs.create_sdk_url }} + seed_plugin_url: ${{ steps.urls.outputs.seed_plugin_url }} eslint_plugin_sdk_url: ${{ steps.urls.outputs.eslint_plugin_sdk_url }} steps: @@ -40,9 +41,8 @@ jobs: uses: ./.github/actions/install-deps # pkg.pr.new URLs are deterministic from the abbreviated (7-char) head SHA, - # so they can be computed BEFORE publishing. SDK and create-sdk can use - # compact URLs because they already exist on npm. The new ESLint plugin - # needs a repository-qualified URL until its first npm release. + # so they can be computed BEFORE publishing. An unpublished package makes + # a combined publish use repository-qualified URLs for every package. # Computing all URLs up front lets one publish call include every package; # separate publishes would each rewrite the PR comment (last wins). - name: Compute preview URLs @@ -51,9 +51,9 @@ jobs: SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | SHORT_SHA="${SHA:0:7}" - SDK_URL="https://pkg.pr.new/@tailor-platform/sdk@${SHORT_SHA}" - CREATE_SDK_URL="https://pkg.pr.new/@tailor-platform/create-sdk@${SHORT_SHA}" - SEED_PLUGIN_URL="https://pkg.pr.new/@tailor-platform/sdk-plugin-seed@${SHORT_SHA}" + SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk@${SHORT_SHA}" + CREATE_SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/create-sdk@${SHORT_SHA}" + SEED_PLUGIN_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk-plugin-seed@${SHORT_SHA}" ESLINT_PLUGIN_SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/eslint-plugin-sdk@${SHORT_SHA}" { echo "sdk_url=$SDK_URL" @@ -80,7 +80,7 @@ jobs: pnpm --config.verify-deps-before-run=false --filter @tailor-platform/create-sdk build - name: Publish to pkg.pr.new - run: pnpm --config.verify-deps-before-run=false exec pkg-pr-new publish --pnpm --compact --commentWithSha --packageManager=pnpm "packages/sdk" "packages/eslint-plugin-sdk" "packages/create-sdk" "packages/sdk-plugin-seed" "packages/sdk-plugin-tailordb-erd" # zizmor: ignore[use-trusted-publishing] + run: pnpm --config.verify-deps-before-run=false exec pkg-pr-new publish --pnpm --no-compact --commentWithSha --packageManager=pnpm "packages/sdk" "packages/eslint-plugin-sdk" "packages/create-sdk" "packages/sdk-plugin-seed" "packages/sdk-plugin-tailordb-erd" # zizmor: ignore[use-trusted-publishing] init-smoke: needs: [publish] @@ -93,20 +93,25 @@ jobs: - runtime: node pm: pnpm node: 22 + template: generators - runtime: node pm: npm node: 24 + template: hello-world - runtime: node pm: yarn node: 22 + template: hello-world - runtime: bun pm: bun node: 24 + template: hello-world - runtime: bun pm: pnpm node: 24 + template: hello-world runs-on: ubuntu-latest - name: Smoke (${{ matrix.runtime }}, ${{ matrix.pm }}, node${{ matrix.node }}) + name: Smoke (${{ matrix.template }}, ${{ matrix.runtime }}, ${{ matrix.pm }}, node${{ matrix.node }}) timeout-minutes: 10 permissions: contents: read @@ -150,9 +155,10 @@ jobs: env: CREATE_SDK_URL: ${{ needs.publish.outputs.create_sdk_url }} CREATE_SDK_NO_INSTALL: "1" + TEMPLATE: ${{ matrix.template }} run: | cd /tmp - npx --yes --package="$CREATE_SDK_URL" create-sdk test-init-app --template hello-world + npx --yes --package="$CREATE_SDK_URL" create-sdk test-init-app --template "$TEMPLATE" - name: Verify generated files run: | @@ -160,12 +166,15 @@ jobs: test -f /tmp/test-init-app/package.json test -f /tmp/test-init-app/tsconfig.json - - name: Verify SDK dependency uses pkg.pr.new URL + - name: Verify generated dependencies use pkg.pr.new URLs env: EXPECTED_SDK_URL: ${{ needs.publish.outputs.sdk_url }} + EXPECTED_SEED_PLUGIN_URL: ${{ needs.publish.outputs.seed_plugin_url }} EXPECTED_ESLINT_PLUGIN_SDK_URL: ${{ needs.publish.outputs.eslint_plugin_sdk_url }} + TEMPLATE: ${{ matrix.template }} run: | SDK_VERSION=$(jq -r '.devDependencies["@tailor-platform/sdk"]' /tmp/test-init-app/package.json) + SEED_PLUGIN_VERSION=$(jq -r '.devDependencies["@tailor-platform/sdk-plugin-seed"] // empty' /tmp/test-init-app/package.json) ESLINT_PLUGIN_SDK_VERSION=$(jq -r '.devDependencies["@tailor-platform/eslint-plugin-sdk"]' /tmp/test-init-app/package.json) echo "SDK version in generated project: $SDK_VERSION" echo "Expected SDK URL: $EXPECTED_SDK_URL" @@ -179,6 +188,14 @@ jobs: echo "Error: ESLint plugin dependency should be '$EXPECTED_ESLINT_PLUGIN_SDK_URL' but got '$ESLINT_PLUGIN_SDK_VERSION'" exit 1 fi + if [[ "$TEMPLATE" = "generators" ]]; then + echo "Seed plugin version in generated project: $SEED_PLUGIN_VERSION" + echo "Expected seed plugin URL: $EXPECTED_SEED_PLUGIN_URL" + if [[ "$SEED_PLUGIN_VERSION" != "$EXPECTED_SEED_PLUGIN_URL" ]]; then + echo "Error: seed plugin dependency should be '$EXPECTED_SEED_PLUGIN_URL' but got '$SEED_PLUGIN_VERSION'" + exit 1 + fi + fi # Lockfile-less install is expected: scaffolded projects have no lockfile. # Yarn 4 PnP is not tested here; this validates node_modules-based resolution. @@ -200,9 +217,13 @@ jobs: - name: Verify SDK installed env: PM: ${{ matrix.pm }} + TEMPLATE: ${{ matrix.template }} run: | test -d /tmp/test-init-app/node_modules/@tailor-platform/sdk test -d /tmp/test-init-app/node_modules/@tailor-platform/eslint-plugin-sdk + if [ "$TEMPLATE" = "generators" ]; then + test -d /tmp/test-init-app/node_modules/@tailor-platform/sdk-plugin-seed + fi echo "SDK dependency successfully installed with $PM" - name: Verify generated project lint diff --git a/scripts/pkg-pr-new-workflow.test.js b/scripts/pkg-pr-new-workflow.test.js new file mode 100644 index 0000000000..80a34fdf57 --- /dev/null +++ b/scripts/pkg-pr-new-workflow.test.js @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +const workflow = readFileSync( + new URL("../.github/workflows/pkg-pr-new.yml", import.meta.url), + "utf8", +); + +describe("pkg.pr.new workflow", () => { + test("uses repository-qualified preview URLs for the combined publish", () => { + for (const packageName of ["sdk", "create-sdk", "sdk-plugin-seed"]) { + expect(workflow).toContain( + `https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/${packageName}@\${SHORT_SHA}`, + ); + } + + const publishCommand = workflow.split("\n").find((line) => line.includes("pkg-pr-new publish")); + expect(publishCommand).toBeDefined(); + expect(publishCommand).toContain("--no-compact"); + }); + + test("smoke-tests the generators template seed dependency", () => { + expect(workflow).toContain("template: generators"); + expect(workflow).toContain("EXPECTED_SEED_PLUGIN_URL"); + expect(workflow).toContain('.devDependencies["@tailor-platform/sdk-plugin-seed"]'); + }); +}); From f52ceb8765ea460055063b439d462e24d054141f Mon Sep 17 00:00:00 2001 From: dqn Date: Mon, 20 Jul 2026 10:27:06 +0900 Subject: [PATCH 12/18] fix(ci): use full SHA for preview URLs --- .github/workflows/pkg-pr-new.yml | 11 +++++------ scripts/pkg-pr-new-workflow.test.js | 4 +++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 4147a3f844..abd33e305b 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -40,7 +40,7 @@ jobs: - name: Install deps uses: ./.github/actions/install-deps - # pkg.pr.new URLs are deterministic from the abbreviated (7-char) head SHA, + # Non-compact pkg.pr.new URLs are deterministic from the full head SHA, # so they can be computed BEFORE publishing. An unpublished package makes # a combined publish use repository-qualified URLs for every package. # Computing all URLs up front lets one publish call include every package; @@ -50,11 +50,10 @@ jobs: env: SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | - SHORT_SHA="${SHA:0:7}" - SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk@${SHORT_SHA}" - CREATE_SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/create-sdk@${SHORT_SHA}" - SEED_PLUGIN_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk-plugin-seed@${SHORT_SHA}" - ESLINT_PLUGIN_SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/eslint-plugin-sdk@${SHORT_SHA}" + SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk@${SHA}" + CREATE_SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/create-sdk@${SHA}" + SEED_PLUGIN_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/sdk-plugin-seed@${SHA}" + ESLINT_PLUGIN_SDK_URL="https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/eslint-plugin-sdk@${SHA}" { echo "sdk_url=$SDK_URL" echo "create_sdk_url=$CREATE_SDK_URL" diff --git a/scripts/pkg-pr-new-workflow.test.js b/scripts/pkg-pr-new-workflow.test.js index 80a34fdf57..f4bf71ea0a 100644 --- a/scripts/pkg-pr-new-workflow.test.js +++ b/scripts/pkg-pr-new-workflow.test.js @@ -10,10 +10,12 @@ describe("pkg.pr.new workflow", () => { test("uses repository-qualified preview URLs for the combined publish", () => { for (const packageName of ["sdk", "create-sdk", "sdk-plugin-seed"]) { expect(workflow).toContain( - `https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/${packageName}@\${SHORT_SHA}`, + `https://pkg.pr.new/tailor-platform/sdk/@tailor-platform/${packageName}@\${SHA}`, ); } + expect(workflow).not.toContain("SHORT_SHA"); + const publishCommand = workflow.split("\n").find((line) => line.includes("pkg-pr-new publish")); expect(publishCommand).toBeDefined(); expect(publishCommand).toContain("--no-compact"); From 32ce096f96c487aba604bd356c82d22c4fbb9883 Mon Sep 17 00:00:00 2001 From: dqn Date: Mon, 20 Jul 2026 13:34:26 +0900 Subject: [PATCH 13/18] fix(sdk): run generated seed validation through CLI --- .../cli/commands/setup/branch.workflow.yml | 6 +-- .../src/cli/commands/setup/generate.test.ts | 46 +++++++++++++++++++ .../src/cli/commands/setup/tag.workflow.yml | 6 +-- .../sdk/src/cli/commands/setup/templates.ts | 10 +++- .../cli/commands/setup/workflow-lint.test.ts | 8 ++-- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/packages/sdk/src/cli/commands/setup/branch.workflow.yml b/packages/sdk/src/cli/commands/setup/branch.workflow.yml index 264ff2a1d2..67f73ec84e 100644 --- a/packages/sdk/src/cli/commands/setup/branch.workflow.yml +++ b/packages/sdk/src/cli/commands/setup/branch.workflow.yml @@ -55,10 +55,8 @@ jobs: # __WORKING_DIRECTORY__ # __SEED_VALIDATE_START__ - id: tailor-seed-validate - uses: tailor-platform/actions/seed-validate@a3c3032e14277e81039eb26ae1a425adb22eb532 # v1.7.0 - with: - package-manager: __PACKAGE_MANAGER__ - # __WORKING_DIRECTORY__ + run: __TAILOR_CLI__ seed validate + # __WORKING_DIRECTORY__ # __SEED_VALIDATE_END__ - id: tailor-drift-check uses: tailor-platform/actions/drift-check@a3c3032e14277e81039eb26ae1a425adb22eb532 # v1.7.0 diff --git a/packages/sdk/src/cli/commands/setup/generate.test.ts b/packages/sdk/src/cli/commands/setup/generate.test.ts index d71486fd0e..06e97ecd1d 100644 --- a/packages/sdk/src/cli/commands/setup/generate.test.ts +++ b/packages/sdk/src/cli/commands/setup/generate.test.ts @@ -38,6 +38,10 @@ const tagBase: RenderTagParams = { packageManager: "pnpm", }; +type GeneratedWorkflow = { + jobs: Record> }>; +}; + describe("detectPackageManager", () => { const testDir = path.join( "/tmp", @@ -504,6 +508,48 @@ describe("renderTagWorkflow", () => { }); }); +describe("seed validation step", () => { + test.each([ + ["pnpm", "pnpm exec tailor seed validate"], + ["npm", "npx tailor seed validate"], + ["yarn", "yarn tailor seed validate"], + ["bun", "bunx tailor seed validate"], + ] as const)( + "uses the installed CLI for %s in branch and tag workflows", + (packageManager, run) => { + const workflows = [ + renderBranchWorkflow({ + ...branchBase, + packageManager, + seedValidate: true, + workingDirectory: "apps/api", + }).content, + renderTagWorkflow({ + ...tagBase, + packageManager, + seedValidate: true, + workingDirectory: "apps/api", + }).content, + ]; + + for (const content of workflows) { + const workflow = parseYAML(content) as GeneratedWorkflow; + const seedValidateStep = workflow.jobs["tailor-plan"]?.steps.find( + (step) => step.id === "tailor-seed-validate", + ); + + expect(seedValidateStep).toEqual({ + id: "tailor-seed-validate", + run, + "working-directory": "apps/api", + }); + expect(content).not.toContain("tailor-platform/actions/seed-validate@"); + expect(content).not.toContain(".tailor-sdk/exec.mjs"); + } + }, + ); +}); + describe("detectDefaultBranch", () => { test("strips the origin/ prefix", () => { const run = vi.fn().mockReturnValue("origin/main"); diff --git a/packages/sdk/src/cli/commands/setup/tag.workflow.yml b/packages/sdk/src/cli/commands/setup/tag.workflow.yml index 2a1d6aa3a4..17a9eeb81f 100644 --- a/packages/sdk/src/cli/commands/setup/tag.workflow.yml +++ b/packages/sdk/src/cli/commands/setup/tag.workflow.yml @@ -59,10 +59,8 @@ jobs: # __WORKING_DIRECTORY__ # __SEED_VALIDATE_START__ - id: tailor-seed-validate - uses: tailor-platform/actions/seed-validate@a3c3032e14277e81039eb26ae1a425adb22eb532 # v1.7.0 - with: - package-manager: __PACKAGE_MANAGER__ - # __WORKING_DIRECTORY__ + run: __TAILOR_CLI__ seed validate + # __WORKING_DIRECTORY__ # __SEED_VALIDATE_END__ - id: tailor-drift-check uses: tailor-platform/actions/drift-check@a3c3032e14277e81039eb26ae1a425adb22eb532 # v1.7.0 diff --git a/packages/sdk/src/cli/commands/setup/templates.ts b/packages/sdk/src/cli/commands/setup/templates.ts index 8789b8673c..520812d08d 100644 --- a/packages/sdk/src/cli/commands/setup/templates.ts +++ b/packages/sdk/src/cli/commands/setup/templates.ts @@ -9,10 +9,17 @@ import tagTemplate from "./tag.workflow.yml"; // Bump on material template-structure changes (managed step ids, placeholders) // so old/new generations stay distinguishable in the lock. /** Template schema version, tracked per target in the lock file. */ -export const TEMPLATE_VERSION = 7; +export const TEMPLATE_VERSION = 8; export type PackageManager = "pnpm" | "yarn" | "npm" | "bun"; +const TAILOR_CLI_COMMANDS: Record = { + pnpm: "pnpm exec tailor", + npm: "npx tailor", + yarn: "yarn tailor", + bun: "bunx tailor", +}; + const HEADER = `# Generated by \`tailor setup\` — managed by the Tailor SDK. # # - Jobs and steps whose id starts with \`tailor-\` are managed by the SDK. @@ -172,6 +179,7 @@ function applyCommon( .replaceAll("__WORKSPACE_NAME__", () => params.workspaceName) .replaceAll("__ENVIRONMENT__", () => environment) .replaceAll("__PACKAGE_MANAGER__", () => packageManager) + .replaceAll("__TAILOR_CLI__", () => TAILOR_CLI_COMMANDS[packageManager]) .replaceAll("__APP_DIR__", () => workingDirectory ?? "."); } diff --git a/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts b/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts index d3708847ba..5a107240cd 100644 --- a/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts +++ b/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts @@ -244,13 +244,14 @@ describe.skipIf(!actionlintAvailable)("actionlint validation of renderBranchWork expect(ok, `actionlint errors:\n${output}`).toBe(true); }); - // workingDirectory + environment - test("branch / npm / with workingDirectory + environment", () => { + // seed validation + workingDirectory + environment + test("branch / npm / with seed validation + workingDirectory + environment", () => { const { content } = renderBranchWorkflow({ ...COMMON, branch: "develop", packageManager: "npm", erdPreview: null, + seedValidate: true, workingDirectory: "apps/api", environment: "staging", }); @@ -272,12 +273,13 @@ describe.skipIf(!actionlintAvailable)("actionlint validation of renderTagWorkflo params: { tagPattern: "v*", packageManager: pm, branch: "main" }, })), { - name: "tag / pnpm / with guard + workingDirectory + environment", + name: "tag / pnpm / with guard + seed validation + workingDirectory + environment", fileName: "tag-pnpm-guard-dir-env", params: { tagPattern: "release-*", packageManager: "pnpm" as const, branch: "main", + seedValidate: true, workingDirectory: "apps/backend", environment: "production", }, From a9d7ca003b08cd3cefa62389ce2e4a60d14c19a2 Mon Sep 17 00:00:00 2001 From: dqn Date: Mon, 20 Jul 2026 14:24:47 +0900 Subject: [PATCH 14/18] chore: release seed migration codemod --- .changeset/extract-seed-plugin.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/extract-seed-plugin.md b/.changeset/extract-seed-plugin.md index f03da3c36f..55a0bca464 100644 --- a/.changeset/extract-seed-plugin.md +++ b/.changeset/extract-seed-plugin.md @@ -1,5 +1,6 @@ --- "@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch --- `seedPlugin` no longer generates the `exec.mjs` seed runner. Seeding and validation move to the `tailor seed` commands provided by the `@tailor-platform/sdk-plugin-seed` CLI plugin: install it with `npm install -D @tailor-platform/sdk-plugin-seed`, replace `node /exec.mjs` with `tailor seed apply` and `node /exec.mjs validate` with `tailor seed validate`, and delete the stale generated `exec.mjs`. Seed data and schema generation (`data/*.jsonl`, `data/*.schema.ts`) is unchanged. Because the plugin reads the config at run time, `machineUserName` changes in seedPlugin options now take effect without regenerating. `@tailor-platform/sdk/cli` gains `loadSeedContext` (and `SeedContext` types) for this, `SeedData` is now JSON-typed, and `executeScript` accepts a plain object `invoker` (`ScriptInvoker`). From 6e35c83080824fd8841168897162e21ae721a955 Mon Sep 17 00:00:00 2001 From: dqn Date: Tue, 21 Jul 2026 16:17:30 +0900 Subject: [PATCH 15/18] fix(seed): reject non-object JSONL rows --- packages/sdk-plugin-seed/src/jsonl.test.ts | 7 +++++++ packages/sdk-plugin-seed/src/jsonl.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/sdk-plugin-seed/src/jsonl.test.ts b/packages/sdk-plugin-seed/src/jsonl.test.ts index 0548f93189..681619d423 100644 --- a/packages/sdk-plugin-seed/src/jsonl.test.ts +++ b/packages/sdk-plugin-seed/src/jsonl.test.ts @@ -36,4 +36,11 @@ describe("loadSeedData", () => { const dir = makeDataDir({ "Bad.jsonl": '{"ok":true}\nnot-json\n' }); expect(() => loadSeedData(dir, ["Bad"])).toThrow(/Bad\.jsonl at line 2/); }); + + test.each(["null", "[]", '"text"', "42"])("rejects non-object JSON rows: %s", (row) => { + const dir = makeDataDir({ "Bad.jsonl": `${row}\n` }); + expect(() => loadSeedData(dir, ["Bad"])).toThrow( + /Invalid seed row in .*Bad\.jsonl at line 1: expected a JSON object/, + ); + }); }); diff --git a/packages/sdk-plugin-seed/src/jsonl.ts b/packages/sdk-plugin-seed/src/jsonl.ts index 41b70b906e..ae4bd18592 100644 --- a/packages/sdk-plugin-seed/src/jsonl.ts +++ b/packages/sdk-plugin-seed/src/jsonl.ts @@ -25,14 +25,21 @@ export function loadSeedData(dataDir: string, typeNames: string[]): SeedData { } data[typeName] = content ? content.split("\n").map((line, index) => { + let value: unknown; try { - return JSON.parse(line) as SeedData[string][number]; + value = JSON.parse(line); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Invalid JSON in ${jsonlPath} at line ${index + 1}: ${message}`, { cause: error, }); } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error( + `Invalid seed row in ${jsonlPath} at line ${index + 1}: expected a JSON object`, + ); + } + return value as SeedData[string][number]; }) : []; } From b80e045904179bad39026afde107ac2c4c43c4fb Mon Sep 17 00:00:00 2001 From: dqn Date: Tue, 21 Jul 2026 16:18:37 +0900 Subject: [PATCH 16/18] docs(sdk): clarify script invoker shape --- packages/sdk/src/cli/shared/script-executor.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/sdk/src/cli/shared/script-executor.ts b/packages/sdk/src/cli/shared/script-executor.ts index 1a8ab0ab56..8b37fbf2b5 100644 --- a/packages/sdk/src/cli/shared/script-executor.ts +++ b/packages/sdk/src/cli/shared/script-executor.ts @@ -11,10 +11,7 @@ import type { MessageInitShape } from "@bufbuild/protobuf"; import type { AuthInvokerSchema } from "@tailor-platform/tailor-proto/auth_resource_pb"; import type { Jsonifiable } from "type-fest"; -/** - * Auth invoker for script execution: a plain init object or a constructed - * AuthInvoker message. - */ +/** Authentication context for script execution, provided as a plain object. */ export type ScriptInvoker = MessageInitShape; /** From 19fa0e870ded6d9e7115b9c9a0eee79e68938854 Mon Sep 17 00:00:00 2001 From: dqn Date: Tue, 21 Jul 2026 16:22:46 +0900 Subject: [PATCH 17/18] test(seed): cover apply command flow --- packages/sdk-plugin-seed/src/apply.test.ts | 149 +++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 packages/sdk-plugin-seed/src/apply.test.ts diff --git a/packages/sdk-plugin-seed/src/apply.test.ts b/packages/sdk-plugin-seed/src/apply.test.ts new file mode 100644 index 0000000000..7da0c53133 --- /dev/null +++ b/packages/sdk-plugin-seed/src/apply.test.ts @@ -0,0 +1,149 @@ +import { runCommand } from "politty"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { z } from "zod"; +import { seedApplyCommand } from "./apply"; +import { commonArgs } from "./shared/args"; + +const sdk = vi.hoisted(() => ({ + bundleSeedScript: vi.fn(), + chunkSeedData: vi.fn(), + executeScript: vi.fn(), + initOperatorClient: vi.fn(), + loadAccessToken: vi.fn(), + loadSeedContext: vi.fn(), + loadWorkspaceId: vi.fn(), + show: vi.fn(), + truncate: vi.fn(), +})); + +const loadSeedData = vi.hoisted(() => vi.fn()); + +const logger = vi.hoisted(() => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + log: vi.fn(), + newline: vi.fn(), + out: vi.fn(), + success: vi.fn(), + warn: vi.fn(), +})); + +vi.mock("@tailor-platform/sdk/cli", () => sdk); +vi.mock("./jsonl", () => ({ loadSeedData })); +vi.mock("./shared/logger", () => ({ logger })); + +beforeEach(() => { + vi.resetAllMocks(); + sdk.loadSeedContext.mockResolvedValue({ + distPath: "/seed", + idpUser: { + seedScriptCode: "seed-user-code", + truncateScriptCode: "truncate-user-code", + }, + machineUserName: undefined, + namespaces: [ + { + dependencies: { User: [] }, + namespace: "tailordb", + selfRefTypes: [], + types: ["User"], + }, + ], + }); + sdk.show.mockResolvedValue({ auth: "auth" }); + sdk.loadAccessToken.mockResolvedValue("token"); + sdk.loadWorkspaceId.mockResolvedValue("workspace-id"); + sdk.initOperatorClient.mockReturnValue({}); + sdk.truncate.mockResolvedValue(undefined); + sdk.executeScript.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ + error: undefined, + logs: "", + result: + name === "truncate-idp-user.ts" + ? '{"success":true,"deleted":1}' + : '{"success":true,"processed":1}', + success: true, + }), + ); + loadSeedData.mockImplementation((_dataDir: string, typeNames: string[]) => + Object.fromEntries( + typeNames.map((typeName) => [typeName, typeName === "_User" ? [{ name: "Ada" }] : []]), + ), + ); +}); + +async function runApply(args: string[]): Promise { + const result = await runCommand(seedApplyCommand, ["--machine-user", "manager", ...args], { + // Strip unknown global arguments like the plugin entrypoint. + globalArgs: z.object(commonArgs), + }); + expect(result.exitCode).toBe(0); +} + +describe("seedApplyCommand", () => { + test("truncates all TailorDB data and the IdP user before seeding by default", async () => { + await runApply(["--truncate", "--yes"]); + + expect(sdk.truncate).toHaveBeenCalledWith({ + all: true, + configPath: "tailor.config.ts", + profile: undefined, + workspaceId: undefined, + }); + expect(sdk.executeScript).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: "truncate-idp-user.ts" }), + ); + expect(sdk.executeScript).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: "seed-idp-user.ts" }), + ); + }); + + test("limits truncation to a namespace and excludes the IdP user", async () => { + await runApply(["--truncate", "--yes", "--namespace", "tailordb"]); + + expect(sdk.truncate).toHaveBeenCalledWith({ + configPath: "tailor.config.ts", + namespace: "tailordb", + profile: undefined, + workspaceId: undefined, + }); + expect(sdk.executeScript).not.toHaveBeenCalled(); + }); + + test("limits truncation to selected TailorDB types and excludes the IdP user", async () => { + await runApply(["--truncate", "--yes", "User"]); + + expect(sdk.truncate).toHaveBeenCalledWith({ + configPath: "tailor.config.ts", + profile: undefined, + types: ["User"], + workspaceId: undefined, + }); + expect(sdk.executeScript).not.toHaveBeenCalled(); + }); + + test("truncates and seeds only the IdP user when it is the selected type", async () => { + await runApply(["--truncate", "--yes", "_User"]); + + expect(sdk.truncate).not.toHaveBeenCalled(); + expect(sdk.executeScript).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: "truncate-idp-user.ts" }), + ); + expect(sdk.executeScript).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: "seed-idp-user.ts" }), + ); + }); + + test("skips IdP user truncation and seeding with --skip-idp", async () => { + await runApply(["--truncate", "--yes", "--skip-idp"]); + + expect(sdk.truncate).toHaveBeenCalledWith(expect.objectContaining({ all: true })); + expect(sdk.executeScript).not.toHaveBeenCalled(); + }); +}); From f980b246ef5e3fb02acc433718cba6c2490b58d6 Mon Sep 17 00:00:00 2001 From: dqn Date: Tue, 21 Jul 2026 16:55:31 +0900 Subject: [PATCH 18/18] fix(deps): update vulnerable transitive dependencies --- pnpm-lock.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ac0aebc5d..36bffdbb2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2890,8 +2890,8 @@ packages: brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} buffer@5.7.1: @@ -4101,8 +4101,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -6132,7 +6132,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -6781,7 +6781,7 @@ snapshots: launch-editor@2.14.1: dependencies: picocolors: 1.1.1 - shell-quote: 1.8.4 + shell-quote: 1.10.0 lefthook-darwin-arm64@2.1.10: optional: true @@ -6938,7 +6938,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: @@ -7408,7 +7408,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.10.0: {} siginfo@2.0.0: {}