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..55a0bca464 --- /dev/null +++ b/.changeset/extract-seed-plugin.md @@ -0,0 +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`). diff --git a/.github/actions/install-deps/action.yml b/.github/actions/install-deps/action.yml index 5334a33d2a..b6a2e73856 100644 --- a/.github/actions/install-deps/action.yml +++ b/.github/actions/install-deps/action.yml @@ -32,14 +32,12 @@ runs: run: pnpm install shell: bash - # Build SDK so that the bin entry points to a real dist file. The ERD - # plugin is built too: example/tailor.config.ts imports it, which - # resolves to dist. + # Build the SDK and CLI plugins so their bin and import entries point to real dist files. - name: Build SDK packages - run: pnpm --filter @tailor-platform/sdk --filter @tailor-platform/sdk-plugin-tailordb-erd run build + run: pnpm --filter @tailor-platform/sdk --filter @tailor-platform/sdk-plugin-seed --filter @tailor-platform/sdk-plugin-tailordb-erd 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-plugin-tailordb-erd' --filter '!@tailor-platform/sdk' --filter '!@tailor-platform/sdk-plugin-seed' --filter '!@tailor-platform/sdk-plugin-tailordb-erd' rebuild --pending shell: bash diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 908c310a0b..e5f8d79e7f 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/eslint-plugin-sdk/**' - 'packages/sdk-codemod/**' - 'packages/sdk-plugin-tailordb-erd/**' diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 1536aa51ae..abd33e305b 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: @@ -39,10 +40,9 @@ jobs: - name: Install deps 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. + # 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; # separate publishes would each rewrite the PR comment (last wins). - name: Compute preview URLs @@ -50,17 +50,19 @@ 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@${SHORT_SHA}" - CREATE_SDK_URL="https://pkg.pr.new/@tailor-platform/create-sdk@${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" + echo "seed_plugin_url=$SEED_PLUGIN_URL" echo "eslint_plugin_sdk_url=$ESLINT_PLUGIN_SDK_URL" } >> "$GITHUB_OUTPUT" echo "SDK preview: $SDK_URL" echo "create-sdk preview: $CREATE_SDK_URL" + echo "seed plugin preview: $SEED_PLUGIN_URL" echo "ESLint plugin preview: $ESLINT_PLUGIN_SDK_URL" # `prepare-templates.js` rewrites template manifests but leaves the @@ -70,13 +72,14 @@ jobs: - name: Build create-sdk with pkg.pr.new dependencies env: TAILOR_TEMPLATE_SDK_VERSION: ${{ steps.urls.outputs.sdk_url }} + TAILOR_TEMPLATE_SEED_PLUGIN_VERSION: ${{ steps.urls.outputs.seed_plugin_url }} TAILOR_TEMPLATE_ESLINT_PLUGIN_VERSION: ${{ steps.urls.outputs.eslint_plugin_sdk_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/eslint-plugin-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 --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] @@ -89,20 +92,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 @@ -146,9 +154,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: | @@ -156,12 +165,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" @@ -175,6 +187,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. @@ -196,9 +216,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/example/package.json b/example/package.json index af87063c7b..b224b0058f 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/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); 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 c1b06694ad..81e6516da9 100644 --- a/packages/create-sdk/scripts/prepare-templates.js +++ b/packages/create-sdk/scripts/prepare-templates.js @@ -3,41 +3,40 @@ 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; -const eslintPluginVersionOrUrl = process.env.TAILOR_TEMPLATE_ESLINT_PLUGIN_VERSION; - -let sdkVersion; -if (sdkVersionOrUrl) { - // If TAILOR_TEMPLATE_SDK_VERSION is set, use it (can be version string or pkg-pr-new URL) - sdkVersion = sdkVersionOrUrl; - console.log(`Using SDK version from environment: ${sdkVersion}`); -} 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")); - sdkVersion = tailorSdkPackageJson.version; - console.log(`Using SDK version from package.json: ${sdkVersion}`); +// 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; } -let eslintPluginVersion; -if (eslintPluginVersionOrUrl) { - eslintPluginVersion = eslintPluginVersionOrUrl; - console.log(`Using ESLint plugin version from environment: ${eslintPluginVersion}`); -} else { - const eslintPluginPackageJsonPath = resolve( - import.meta.dirname, - "..", - "..", - "eslint-plugin-sdk", - "package.json", - ); - const eslintPluginPackageJson = JSON.parse(readFileSync(eslintPluginPackageJsonPath, "utf-8")); - eslintPluginVersion = eslintPluginPackageJson.version; - console.log(`Using ESLint plugin version from package.json: ${eslintPluginVersion}`); -} +const packageVersions = { + "@tailor-platform/sdk": resolveVersion({ + envVar: "TAILOR_TEMPLATE_SDK_VERSION", + packageDir: "sdk", + label: "SDK", + }), + "@tailor-platform/eslint-plugin-sdk": resolveVersion({ + envVar: "TAILOR_TEMPLATE_ESLINT_PLUGIN_VERSION", + packageDir: "eslint-plugin-sdk", + label: "ESLint plugin", + }), + "@tailor-platform/sdk-plugin-seed": resolveVersion({ + envVar: "TAILOR_TEMPLATE_SEED_PLUGIN_VERSION", + packageDir: "sdk-plugin-seed", + label: "seed plugin", + }), +}; -// Update version in each template's package.json +// 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()) @@ -47,18 +46,22 @@ 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"] = sdkVersion; - } - if (content.devDependencies?.["@tailor-platform/sdk"]) { - content.devDependencies["@tailor-platform/sdk"] = sdkVersion; - } - if (content.devDependencies?.["@tailor-platform/eslint-plugin-sdk"]) { - content.devDependencies["@tailor-platform/eslint-plugin-sdk"] = eslintPluginVersion; + 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 package dependencies in ${template}/package.json`); + if (updated.length > 0) { + console.log(`Updated ${template}/package.json to use ${updated.join(", ")}`); + } } // Copy .gitignore to __dot__gitignore 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 e7db5d518b..57582abdd6 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 .", @@ -17,6 +19,7 @@ "devDependencies": { "@tailor-platform/eslint-plugin-sdk": "workspace:^", "@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 7e5c2db699..54f8747db1 100644 --- a/packages/sdk-codemod/src/registry.ts +++ b/packages/sdk-codemod/src/registry.ts @@ -1274,6 +1274,40 @@ 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"], + sourceStringSuspiciousPatterns: ["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.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(); + }); +}); diff --git a/packages/sdk-plugin-seed/src/apply.ts b/packages/sdk-plugin-seed/src/apply.ts new file mode 100644 index 0000000000..d0cc6c1603 --- /dev/null +++ b/packages/sdk-plugin-seed/src/apply.ts @@ -0,0 +1,442 @@ +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, SeedData } from "@tailor-platform/sdk/cli"; + +interface SeedExecutionContext { + operatorClient: OperatorClient; + workspaceId: string; + authNamespace: string; + machineUserName: string; + dataDir: string; +} + +interface SeedNamespaceParams { + execution: SeedExecutionContext; + namespace: string; + typesToSeed: string[]; + dependencies: Record; + selfRefTypes: 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) => { + 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) { + 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: [] }; +} + +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 { success: true, processed: processedTotals }; + } + + 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 { success: true, processed: processedTotals }; + } + 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: execution.operatorClient, + workspaceId: execution.workspaceId, + name: `seed-${namespace}.ts`, + code: bundled.bundledCode, + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, + invoker: { + namespace: execution.authNamespace, + machineUserName: execution.machineUserName, + }, + }); + + 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) { + logger.error(` Seed failed:\n ${errors.join("\n ")}`, { mode: "plain" }); + success = false; + } + } + return { success, processed: processedTotals }; +} + +interface IdpScriptRun { + execution: SeedExecutionContext; + scriptCode: string; + scriptName: string; + arg?: { users: SeedData[string] }; + indent: string; + reportSuccess: (parsed: Record) => void; +} + +async function runIdpScript( + params: IdpScriptRun, +): Promise<{ success: boolean; parsed: Record }> { + const { execution, scriptCode, scriptName, arg, indent, reportSuccess } = params; + + const result = await executeScript({ + client: execution.operatorClient, + workspaceId: execution.workspaceId, + name: scriptName, + code: scriptCode, + ...(arg ? { arg } : {}), + invoker: { + namespace: execution.authNamespace, + machineUserName: execution.machineUserName, + }, + }); + + const { success, parsed, errors } = parseExecutionResult(result, indent); + reportSuccess(parsed); + if (!success) { + for (const error of errors) { + logger.error(`${indent}${error}`, { mode: "plain" }); + } + } + return { success, parsed }; +} + +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 { success: true, processed: 0 }; + } + logger.log(chalk.dim(` Processing ${rows.length} _User records...`)); + + const { success, parsed } = await runIdpScript({ + execution, + scriptCode, + scriptName: "seed-idp-user.ts", + arg: { users: rows }, + indent: " ", + 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( + execution: SeedExecutionContext, + scriptCode: string, +): Promise { + logger.info("Truncating _User via tailor.idp.Client...", { mode: "plain" }); + + const { success } = await runIdpScript({ + execution, + scriptCode, + scriptName: "truncate-idp-user.ts", + indent: " ", + reportSuccess: (result) => { + if (typeof result.deleted === "number") { + logger.log(chalk.green(` ✓ _User: ${result.deleted} users deleted`)); + } + }, + }); + 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 execution: SeedExecutionContext = { + operatorClient: await initOperatorClient(await loadAccessToken({ profile: args.profile })), + workspaceId: await loadWorkspaceId({ + workspaceId: args["workspace-id"], + profile: args.profile, + }), + authNamespace: appInfo.auth, + machineUserName, + 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) { + if (args.json) { + logger.out({ success: false, cancelled: true, processed: {} }); + } + throw new Error("Truncate cancelled."); + } + + 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(execution, 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 allProcessed: Record = {}; + + 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({ + execution, + namespace, + typesToSeed, + dependencies: nsConfig.dependencies, + selfRefTypes: nsConfig.selfRefTypes, + }); + for (const [type, count] of Object.entries(seeded.processed)) { + allProcessed[type] = (allProcessed[type] ?? 0) + count; + } + if (!seeded.success) { + allSuccess = false; + } + } + + const shouldSeedUser = + hasIdpUser && + !args["skip-idp"] && + (!selection.entitiesToProcess || selection.entitiesToProcess.includes("_User")); + if (shouldSeedUser && context.idpUser) { + const seeded = await seedIdpUser(execution, context.idpUser.seedScriptCode); + 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"); + } + 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..681619d423 --- /dev/null +++ b/packages/sdk-plugin-seed/src/jsonl.test.ts @@ -0,0 +1,46 @@ +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("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/); + }); + + 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 new file mode 100644 index 0000000000..ae4bd18592 --- /dev/null +++ b/packages/sdk-plugin-seed/src/jsonl.ts @@ -0,0 +1,47 @@ +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, index) => { + let value: unknown; + try { + 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]; + }) + : []; + } + 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..0e8a47a271 --- /dev/null +++ b/packages/sdk-plugin-seed/src/shared/args.ts @@ -0,0 +1,121 @@ +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), { + // -v matches the generated seed runner this plugin replaces. + alias: "v", + 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..a2ce9f3101 --- /dev/null +++ b/packages/sdk-plugin-seed/src/validate.ts @@ -0,0 +1,43 @@ +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 (args.json) { + logger.out({ valid: result.valid, path: targetPath }); + } + 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 72d6682223..f13f99a9bb 100644 --- a/packages/sdk/docs/configuration.md +++ b/packages/sdk/docs/configuration.md @@ -110,7 +110,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 668314c3ef..8104716890 100644 --- a/packages/sdk/docs/migration/v2.md +++ b/packages/sdk/docs/migration/v2.md @@ -1251,6 +1251,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 7a6ecacb48..87555de141 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/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", }, diff --git a/packages/sdk/src/cli/lib.ts b/packages/sdk/src/cli/lib.ts index 2f9226e837..087829bb48 100644 --- a/packages/sdk/src/cli/lib.ts +++ b/packages/sdk/src/cli/lib.ts @@ -226,7 +226,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, @@ -237,6 +249,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.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(() => {}); 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..8b37fbf2b5 100644 --- a/packages/sdk/src/cli/shared/script-executor.ts +++ b/packages/sdk/src/cli/shared/script-executor.ts @@ -7,9 +7,13 @@ 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"; +/** Authentication context for script execution, provided as a plain object. */ +export type ScriptInvoker = MessageInitShape; + /** * Default polling interval for script execution status in milliseconds (1 second) */ @@ -30,7 +34,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..e975d2ac67 --- /dev/null +++ b/packages/sdk/src/cli/shared/seed-context.test.ts @@ -0,0 +1,180 @@ +import * as path from "pathe"; +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: { 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; +} + +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] }); + + await expect(loadSeedContext()).rejects.toThrow( + /seedPlugin is not configured in \/proj\/tailor\.config\.ts/, + ); + }); + + 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({})] }); + + await expect(loadSeedContext()).rejects.toThrow(/has no distPath option/); + }); + + test("resolves a relative distPath against the working directory", async () => { + mockLoadResult({ plugins: [seedPluginInstance({ distPath: "./seed" })] }); + + const context = await loadSeedContext(); + expect(context.distPath).toBe(path.resolve("./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: { + // 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" }, + }, + { + type: { name: "Staff" }, + namespace: "main-db", + usernameField: "email", + }, + ), + }, + }); + + 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: fakeAuthService({ + name: "main-auth", + machineUsers: {}, + idProvider: { kind: "OIDC", namespace: "ext-idp" }, + }), + }, + }); + + 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..f4af011984 --- /dev/null +++ b/packages/sdk/src/cli/shared/seed-context.ts @@ -0,0 +1,109 @@ +import * as path from "pathe"; +import { assertUniqueLocalTailorDBTypeNames } from "#/cli/services/tailordb/type-name-validation"; +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 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. + */ +export async function loadSeedContext(options: LoadSeedContextOptions = {}): Promise { + const { config, plugins, application, namespaces } = await loadApplicationNamespaces({ + 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( + `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 | 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.', + ); + } + + // 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 + ? { + idpNamespace: idpUserMeta.idpNamespace, + seedScriptCode: generateIdpSeedScriptCode(idpUserMeta.idpNamespace), + truncateScriptCode: generateIdpTruncateScriptCode(idpUserMeta.idpNamespace), + } + : null; + + return { + config, + distPath: path.resolve(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 51d7f92388..32af6fb7ed 100644 --- a/packages/sdk/src/cli/shared/tailordb-namespaces.ts +++ b/packages/sdk/src/cli/shared/tailordb-namespaces.ts @@ -1,4 +1,4 @@ -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"; @@ -36,15 +36,23 @@ 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 { + /** 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 }); @@ -86,5 +94,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, plugins, namespaces } = await loadApplicationNamespaces(options); return { config, plugins, namespaces }; } diff --git a/packages/sdk/src/plugin/builtin/seed/index.ts b/packages/sdk/src/plugin/builtin/seed/index.ts index fee5193af2..aeac0cf305 100644 --- a/packages/sdk/src/plugin/builtin/seed/index.ts +++ b/packages/sdk/src/plugin/builtin/seed/index.ts @@ -1,20 +1,12 @@ 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, generateLinesDbSchemaFileWithPluginAPI, 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 +31,7 @@ type DisableIdpUserSyncDirections = { idpToUser?: boolean; }; -type SeedPluginOptions = { +export type SeedPluginOptions = { distPath: string; machineUserName?: string; /** @@ -61,719 +53,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,29 +65,22 @@ ${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), `source info missing for type: ${typeName}`, ); - const typeInfo = processSeedTypeInfo(type, ns.namespace); const linesDb = processLinesDb(type, source); // Add reverse FK from userProfile type to _User (opt-out via disableIdpUserSync.userToIdp: true) @@ -823,15 +98,9 @@ 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), + path: path.join(ctx.pluginConfig.distPath, "data", `${linesDb.typeName}.jsonl`), content: "", skipIfExists: true, }); @@ -884,13 +153,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 0242ed20bf..36bffdbb2e 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 @@ -217,6 +220,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 @@ -695,6 +701,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.1)(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(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.1)(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.1)(yaml@2.9.0)) + packages/sdk-plugin-tailordb-erd: dependencies: chalk: @@ -739,7 +785,7 @@ importers: 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.1)(typescript@6.0.3) + version: 0.22.5(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.1)(typescript@6.0.3) typescript: specifier: 6.0.3 version: 6.0.3 @@ -2647,245 +2693,123 @@ packages: '@vue/shared@3.5.38': resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} - '@yuku-codegen/binding-darwin-arm64@0.5.48': - resolution: {integrity: sha512-yo96Oef12WzqnphInfz/eexVse3+kWgfGS5g2S3rFS3dcGn1ENW9xLFDZUP9rh+yP76DOq38wBoFi1+I9+6qBg==} - cpu: [arm64] - os: [darwin] - '@yuku-codegen/binding-darwin-arm64@0.6.3': resolution: {integrity: sha512-pbDcFygFmbvo0jGFq5U0m5Sa9U8aVttVJWbBHZDZ68w/X48HdDWS1V4XvBacs8XkmWbTr/ef5fMGG7HsngqTmg==} cpu: [arm64] os: [darwin] - '@yuku-codegen/binding-darwin-x64@0.5.48': - resolution: {integrity: sha512-aRCTw0EZC4bVosmw//0OMYP5tGWFE0Cu5yUBFkUbhXx/iBzvORcJ2xPNlOp/vtCCo9Ys4vp8b0DigJV6uOVb2g==} - cpu: [x64] - os: [darwin] - '@yuku-codegen/binding-darwin-x64@0.6.3': resolution: {integrity: sha512-qZMrnA4i8OfqL3NMGoOvLdh1vby8cCGsmNo+tJQIIezXO557m3fdQLenz/57GqtBnnGWzWqd/aDp+faNxWlLwQ==} cpu: [x64] os: [darwin] - '@yuku-codegen/binding-freebsd-x64@0.5.48': - resolution: {integrity: sha512-CA0AQAEApDkbw51PdLWMtKPJ41/7rvXsS3SJs+phG7fHJI+MuFzWuLbkucZfZoEOiDscmcsfYIdgL8BsfuyKKQ==} - cpu: [x64] - os: [freebsd] - '@yuku-codegen/binding-freebsd-x64@0.6.3': resolution: {integrity: sha512-2+SFpfem2GBH6BlCTAq6R44bZwuieduwRWHkCQnSgbK8tdEjMwB0Ix0IchryVBn6hdiWCZSTkSE3UILliRXsRQ==} cpu: [x64] os: [freebsd] - '@yuku-codegen/binding-linux-arm-gnu@0.5.48': - resolution: {integrity: sha512-DuSQlk8bH4gpmW3/00P0NLagAcMv8jOxjT40cQmxKRkktr+SUOALCfkT89tdDq3qtY95NR2GXOZ7AjNh7KKqCw==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@yuku-codegen/binding-linux-arm-gnu@0.6.3': resolution: {integrity: sha512-fbxg3cBPdJ++36DXtdzcoKw2xzFov91Wxvmn1khX9MXQbDqJQLJmITZhtokcZsj4uGJe32sUmxAZnKbUtZLjmA==} cpu: [arm] os: [linux] libc: [glibc] - '@yuku-codegen/binding-linux-arm-musl@0.5.48': - resolution: {integrity: sha512-bxj4Ee+wlaJcWJwft2ReJXWw5sfl1qavDz6+dlRdU1xfTEtjPSNiAWhiCHnJR0R4Ygd57DnzSQmAVGvFv6RcGw==} - cpu: [arm] - os: [linux] - libc: [musl] - '@yuku-codegen/binding-linux-arm-musl@0.6.3': resolution: {integrity: sha512-Jk4P7kocGEisSvUFIm1VuHO3hC01LvS3sYAAmVVu1/ve5TuZ0iXyl9kIGtd1ZrgUvchgvZWNOaB+/Kq/RO63FA==} cpu: [arm] os: [linux] libc: [musl] - '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': - resolution: {integrity: sha512-mk5JVWh+0JOe5ue8k17kbYX8uGBoKt3ZqoCyxNh4nYAAcX7+X1tFUiU7jbjctu4vHeejCBFSTdQ021+V31cUCQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@yuku-codegen/binding-linux-arm64-gnu@0.6.3': resolution: {integrity: sha512-i1xE8Bx1YZLheWtBZHD0Mq3nAIDrhgiH7o8VB4GiCbHufKb4XKj4CqSDMWSC0RYPmn//E+UEd1NsE2NbOux1tQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@yuku-codegen/binding-linux-arm64-musl@0.5.48': - resolution: {integrity: sha512-4q3vkrNghbllyxOm2KesFLxCPKHF7r3JyQ7BWZccY1j2Y05yKoIFhoWCqIuQ2W/dpte9RI0+OVfwyxnrKg6fkA==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@yuku-codegen/binding-linux-arm64-musl@0.6.3': resolution: {integrity: sha512-ZeLkC6xZrlDoIJTadHfqTABTmsj2f6wCCtYBYx/RPGgdmQcGLA0NALRl7m0tnK2CT45eNRuOYzsfEZyF0XWM/A==} cpu: [arm64] os: [linux] libc: [musl] - '@yuku-codegen/binding-linux-x64-gnu@0.5.48': - resolution: {integrity: sha512-csd4M1EVrGaohM8acM6gq1zpUA/Rwe2ulUMBKUcwQXm/k6n7cq1A++qdew78SOVb4do3JH1WE+WFwoGQAcWc1w==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@yuku-codegen/binding-linux-x64-gnu@0.6.3': resolution: {integrity: sha512-HNYt7zjIChPcnjZRG42CZq3Zn5mqaRo4UcFLr4mIbmGqdhX5hDDE8O8US8YgYyOUosfbBTBFdsbvFwVAx1TOkQ==} cpu: [x64] os: [linux] libc: [glibc] - '@yuku-codegen/binding-linux-x64-musl@0.5.48': - resolution: {integrity: sha512-KcDuEOT+GFoVKdvAWOv1v9iYjwnmvMZlO+j1Rw+5PYdeFLGWGzv/DD11y4SAAdwXIFcil4T0hibeIaF82WStMg==} - cpu: [x64] - os: [linux] - libc: [musl] - '@yuku-codegen/binding-linux-x64-musl@0.6.3': resolution: {integrity: sha512-/1ttT31dAQc7hGtXWSEYEgzGtakAyO2C+/GqAzIuKXlGLpNPZgXdR8LZ0iDHDalbEr3AuHIPRV43sWLUrHWdsA==} cpu: [x64] os: [linux] libc: [musl] - '@yuku-codegen/binding-win32-arm64@0.5.48': - resolution: {integrity: sha512-HI8qNrI8dWM5BuqIMKsqornRvTNFrE6sm5zToIJ9YIa9zt5+29P7fJ7Nr39EVf6dAWSb6q7JSpScJnRsQ+FgZA==} - cpu: [arm64] - os: [win32] - '@yuku-codegen/binding-win32-arm64@0.6.3': resolution: {integrity: sha512-oAArRDU1lkKg+xFEtiQ7C+/wghpqrkBrFWM05W73S+3sZz8JIHH79Q+Qh5gWls3i8vccitUgCnvln5V7xKn3XQ==} cpu: [arm64] os: [win32] - '@yuku-codegen/binding-win32-x64@0.5.48': - resolution: {integrity: sha512-X5YWJLO6EfBZpeBqO0AYESnUizbpFDWArcvVD61w0PEWQ3CaFRLnbQXs+kpM4ZZfGMfIE22zfA08QSY67q7TNQ==} - cpu: [x64] - os: [win32] - '@yuku-codegen/binding-win32-x64@0.6.3': resolution: {integrity: sha512-bhFDcDsvmp5KuBfWTt4carNo1E4LXH7++S8qqZgDtXVqJxs0xMm7gbuqPihA8kBbphM+NzAUm5qmq6ocfqM6kg==} cpu: [x64] os: [win32] - '@yuku-parser/binding-darwin-arm64@0.5.48': - resolution: {integrity: sha512-If8mb7HH3vqghJ2NNZ8SuHfhsnjVzOxJpB8xcNOXS5WjYrs2mUhHIh5KOIvK13hDOzh0htGeGK3A6MsiEqE7HQ==} - cpu: [arm64] - os: [darwin] - '@yuku-parser/binding-darwin-arm64@0.6.3': resolution: {integrity: sha512-Xate6yyZgvi7da/gdnZy+Vu5jlFB0LRlb5m4MY6Y98KSQeJPZIhoVXMK2Vsl48XOmPlDIS8lge414HUVUEo+hg==} cpu: [arm64] os: [darwin] - '@yuku-parser/binding-darwin-x64@0.5.48': - resolution: {integrity: sha512-EimvPXfspzxf1K11eB6tCW5oiQEXB8g84T2wP1TwzQagdDKo33bkmmVF0B32vTIpXnk/Ifu5IB61izZ1MylljA==} - cpu: [x64] - os: [darwin] - '@yuku-parser/binding-darwin-x64@0.6.3': resolution: {integrity: sha512-WKMZ5UU2HBdGZDEpQoLq+21f1FlS+BjroH1FaVE6zCSeqKmZ7xRP5jIRGtQ4vCYj/k2KHgyABZ16lgK9mTe2Sg==} cpu: [x64] os: [darwin] - '@yuku-parser/binding-freebsd-x64@0.5.48': - resolution: {integrity: sha512-0GcUMrumLHheThY9r5Tp46gaZYzn0irWPS1Zba6WY+vVQfhUtzGiWgXxI6tuXX0N32kEaaEVRpkKctvo6Kx3aQ==} - cpu: [x64] - os: [freebsd] - '@yuku-parser/binding-freebsd-x64@0.6.3': resolution: {integrity: sha512-OWX8V2k4bmtl/DNwU3yz3PZa2QXiSqWN3Xk7pNB5IPt7FcJBDlnpkOcX6h3tjMS7CyKE0lMvPUwwcmWSdbjkyA==} cpu: [x64] os: [freebsd] - '@yuku-parser/binding-linux-arm-gnu@0.5.48': - resolution: {integrity: sha512-8S5T5wjCC73dmmpQeZ49aYsSunIUM3D4Fc6rdK96c+Ayg/p3FmeSPF3xuLZHejcTmqJIIvnbfPlUF+rB6DITjQ==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@yuku-parser/binding-linux-arm-gnu@0.6.3': resolution: {integrity: sha512-/4LzmPXPaCWqIpY1j3+XVb8rbXIratqCte3A4sGEjua6aVhvVxEVAqeKlBsoGkORWLeqbpcxhgxKwOGM17eexA==} cpu: [arm] os: [linux] libc: [glibc] - '@yuku-parser/binding-linux-arm-musl@0.5.48': - resolution: {integrity: sha512-tTmbxvnUHcK2/crS9547vk2SMmsajH1yqJ8ltXhIuHJgqR1v+d9n9KT+kSayo/5CS76LegeYxhMFjEivBH2hFA==} - cpu: [arm] - os: [linux] - libc: [musl] - '@yuku-parser/binding-linux-arm-musl@0.6.3': resolution: {integrity: sha512-Df4jk0M/eNKKQfYzXBBKhKkmJBpB+XoX2LkMxmlK3GN+fxUdeb8EM78wX+1+eLVl5dZNo6f7gOd6oDV0gChevw==} cpu: [arm] os: [linux] libc: [musl] - '@yuku-parser/binding-linux-arm64-gnu@0.5.48': - resolution: {integrity: sha512-KGYCBMqI2zfwyhgq5tpPVNe7jpUeYTBm8DhjdS+zqWNumde/PEC170QE5RHxcOAlsirIDeIUk0jqx+r/axoFSw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@yuku-parser/binding-linux-arm64-gnu@0.6.3': resolution: {integrity: sha512-sRCtDktUgIbbV78SYX3wdGVVm1Hz/nSUS24JgXB4MzUeGNwBNB+eQAWMtBxCGriyJNXK3zwfj+SSgvTmUdPf/A==} cpu: [arm64] os: [linux] libc: [glibc] - '@yuku-parser/binding-linux-arm64-musl@0.5.48': - resolution: {integrity: sha512-2wTSMsCSXLTc2lZUjMAuU5X4cje55u205WJqfV5NWNF6j9pW/tXyxr15dJeekj8ziLqBXzIsj4DbRh4sY/WcjA==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@yuku-parser/binding-linux-arm64-musl@0.6.3': resolution: {integrity: sha512-3J/jV3ROSqlhLyB/6i5EUHxjkom5i59iPvrtiAsnAjHzMsZJJPEke9LSaOsB0rf4MJFH9AmjvsK8gDahTjZy+A==} cpu: [arm64] os: [linux] libc: [musl] - '@yuku-parser/binding-linux-x64-gnu@0.5.48': - resolution: {integrity: sha512-d/6v9UnGglVu1WC2JQyv/5aWSi5fXZeGSlidCfmHp4+N65N1GDKUnFtys5MK5eAPeAjTgSHGGtOc/yCcKTlv3A==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@yuku-parser/binding-linux-x64-gnu@0.6.3': resolution: {integrity: sha512-a5mPn/OMSq2Aa2i7eJXcc37Jtw0b89gDO2mDpXN769b06IirEiqOzLNNJd6R7R8DxoadHzY0nLFhYNChE+jyAg==} cpu: [x64] os: [linux] libc: [glibc] - '@yuku-parser/binding-linux-x64-musl@0.5.48': - resolution: {integrity: sha512-gX19gw6u4ApPy7SYMPKfFlEkrtj6WlORvrTKK3sBQqjyV+8+mUAkQgxXNjHw4RnOiAmVYg7TOlZcg8d+Qqod9A==} - cpu: [x64] - os: [linux] - libc: [musl] - '@yuku-parser/binding-linux-x64-musl@0.6.3': resolution: {integrity: sha512-kQSjfa6zdvotuXEGNKQ9vZZxE+lcEEbTUMSuvY/5+0crlwBCjEqrf9/W9ViFDoQAEt8WJiu/mtVNYkKAOkjLmA==} cpu: [x64] os: [linux] libc: [musl] - '@yuku-parser/binding-win32-arm64@0.5.48': - resolution: {integrity: sha512-w6cQQLbqj3Jcom5Q7ifm103NUOQ9d+Cb4VU5lkrZDjMnwVJ9Hzzg1vCQR7miJuF44vhCXldbme5UryE3giEKlA==} - cpu: [arm64] - os: [win32] - '@yuku-parser/binding-win32-arm64@0.6.3': resolution: {integrity: sha512-ZawdN3R0YKr48BeXCpbax+WDWbgEG6nWyDosVeZasrT5TjgfF4XMP5SfuyMNvRJ4gbTitrcYHZkENSXZ9ncqcg==} cpu: [arm64] os: [win32] - '@yuku-parser/binding-win32-x64@0.5.48': - resolution: {integrity: sha512-4gO0HmG7fzFxrw1rs0dUdnnaY9YgennjETqDWrTSp7x9fmTUOAoN4VsMfP7YyliQeG1WJJHc55O+rOhmsLppow==} - cpu: [x64] - os: [win32] - '@yuku-parser/binding-win32-x64@0.6.3': resolution: {integrity: sha512-NcqZwBXQhKyfC0Eb+yBxXQcPjZoUstD1Dbr3X4UlOD1QiYfnvAYRKCZJ6nQ6KQ5p30dGaKkQeBL4t3qQtDQNWA==} cpu: [x64] @@ -2966,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: @@ -4116,25 +4040,6 @@ packages: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} - rolldown-plugin-dts@0.27.6: - resolution: {integrity: sha512-LK/2xsCvFwpppMPlAYTmBSLcxqYXwPye/BSTgH0hpe1iEbs1j5bYHchRahADh1uHOqLzOOlgciRIJ201yPb0yQ==} - engines: {node: ^22.18.0 || >=24.11.0} - peerDependencies: - '@ts-macro/tsc': ^0.3.6 - '@typescript/native-preview': '>=7.0.0-dev.20260325.1' - rolldown: ^1.0.0 - typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 - vue-tsc: ~3.2.0 || ~3.3.0 - peerDependenciesMeta: - '@ts-macro/tsc': - optional: true - '@typescript/native-preview': - optional: true - typescript: - optional: true - vue-tsc: - optional: true - rolldown-plugin-dts@0.27.9: resolution: {integrity: sha512-d54yt65+ZF/Mk8H6P36As02PAMdaiWRSzVNtJRc1h7nCgUFjuRI4cN2DyTfJyfVpPH6pgy7/2D7YQH1/Rh75Yg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -4196,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: @@ -4611,15 +4516,9 @@ packages: yuku-ast@0.1.7: resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} - yuku-codegen@0.5.48: - resolution: {integrity: sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==} - yuku-codegen@0.6.3: resolution: {integrity: sha512-3c9H521tf1RRDu4cNUySfH01sKlALve4HKu2sITk33gLl5HhsvI6ngSuarpWxMPAiJEgqJc/HTvojWQRnYm9/g==} - yuku-parser@0.5.48: - resolution: {integrity: sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA==} - yuku-parser@0.6.3: resolution: {integrity: sha512-iI6uABvvup9mvv8Mcpz7Tp//gehQlvcSnX4A4/0bf9i6X3RVQDuVUZel8jdpljwlF7WrbKsvD19y55Mc6+sKZw==} @@ -6100,135 +5999,69 @@ snapshots: '@vue/shared@3.5.38': {} - '@yuku-codegen/binding-darwin-arm64@0.5.48': - optional: true - '@yuku-codegen/binding-darwin-arm64@0.6.3': optional: true - '@yuku-codegen/binding-darwin-x64@0.5.48': - optional: true - '@yuku-codegen/binding-darwin-x64@0.6.3': optional: true - '@yuku-codegen/binding-freebsd-x64@0.5.48': - optional: true - '@yuku-codegen/binding-freebsd-x64@0.6.3': optional: true - '@yuku-codegen/binding-linux-arm-gnu@0.5.48': - optional: true - '@yuku-codegen/binding-linux-arm-gnu@0.6.3': optional: true - '@yuku-codegen/binding-linux-arm-musl@0.5.48': - optional: true - '@yuku-codegen/binding-linux-arm-musl@0.6.3': optional: true - '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': - optional: true - '@yuku-codegen/binding-linux-arm64-gnu@0.6.3': optional: true - '@yuku-codegen/binding-linux-arm64-musl@0.5.48': - optional: true - '@yuku-codegen/binding-linux-arm64-musl@0.6.3': optional: true - '@yuku-codegen/binding-linux-x64-gnu@0.5.48': - optional: true - '@yuku-codegen/binding-linux-x64-gnu@0.6.3': optional: true - '@yuku-codegen/binding-linux-x64-musl@0.5.48': - optional: true - '@yuku-codegen/binding-linux-x64-musl@0.6.3': optional: true - '@yuku-codegen/binding-win32-arm64@0.5.48': - optional: true - '@yuku-codegen/binding-win32-arm64@0.6.3': optional: true - '@yuku-codegen/binding-win32-x64@0.5.48': - optional: true - '@yuku-codegen/binding-win32-x64@0.6.3': optional: true - '@yuku-parser/binding-darwin-arm64@0.5.48': - optional: true - '@yuku-parser/binding-darwin-arm64@0.6.3': optional: true - '@yuku-parser/binding-darwin-x64@0.5.48': - optional: true - '@yuku-parser/binding-darwin-x64@0.6.3': optional: true - '@yuku-parser/binding-freebsd-x64@0.5.48': - optional: true - '@yuku-parser/binding-freebsd-x64@0.6.3': optional: true - '@yuku-parser/binding-linux-arm-gnu@0.5.48': - optional: true - '@yuku-parser/binding-linux-arm-gnu@0.6.3': optional: true - '@yuku-parser/binding-linux-arm-musl@0.5.48': - optional: true - '@yuku-parser/binding-linux-arm-musl@0.6.3': optional: true - '@yuku-parser/binding-linux-arm64-gnu@0.5.48': - optional: true - '@yuku-parser/binding-linux-arm64-gnu@0.6.3': optional: true - '@yuku-parser/binding-linux-arm64-musl@0.5.48': - optional: true - '@yuku-parser/binding-linux-arm64-musl@0.6.3': optional: true - '@yuku-parser/binding-linux-x64-gnu@0.5.48': - optional: true - '@yuku-parser/binding-linux-x64-gnu@0.6.3': optional: true - '@yuku-parser/binding-linux-x64-musl@0.5.48': - optional: true - '@yuku-parser/binding-linux-x64-musl@0.6.3': optional: true - '@yuku-parser/binding-win32-arm64@0.5.48': - optional: true - '@yuku-parser/binding-win32-arm64@0.6.3': optional: true - '@yuku-parser/binding-win32-x64@0.5.48': - optional: true - '@yuku-parser/binding-win32-x64@0.6.3': optional: true @@ -6299,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 @@ -6948,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 @@ -7105,7 +6938,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: @@ -7495,21 +7328,6 @@ snapshots: ret@0.1.15: {} - rolldown-plugin-dts@0.27.6(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@6.0.3): - dependencies: - dts-resolver: 3.0.0(oxc-resolver@11.21.3) - get-tsconfig: 5.0.0-beta.5 - obug: 2.1.3 - rolldown: 1.1.5 - yuku-ast: 0.1.7 - yuku-codegen: 0.5.48 - yuku-parser: 0.5.48 - optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260707.2 - typescript: 6.0.3 - transitivePeerDependencies: - - oxc-resolver - rolldown-plugin-dts@0.27.9(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0(oxc-resolver@11.21.3) @@ -7590,7 +7408,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.10.0: {} siginfo@2.0.0: {} @@ -7725,7 +7543,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.22.5(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.1)(typescript@6.0.3): + tsdown@0.22.5(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.1)(typescript@6.0.3): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -7736,7 +7554,7 @@ snapshots: obug: 2.1.3 picomatch: 4.0.5 rolldown: 1.1.5 - rolldown-plugin-dts: 0.27.6(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@6.0.3) + rolldown-plugin-dts: 0.27.9(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@6.0.3) semver: 7.8.5 tinyexec: 1.2.4 tinyglobby: 0.2.17 @@ -7903,22 +7721,6 @@ snapshots: dependencies: '@yuku-toolchain/types': 0.5.43 - yuku-codegen@0.5.48: - dependencies: - '@yuku-toolchain/types': 0.5.43 - optionalDependencies: - '@yuku-codegen/binding-darwin-arm64': 0.5.48 - '@yuku-codegen/binding-darwin-x64': 0.5.48 - '@yuku-codegen/binding-freebsd-x64': 0.5.48 - '@yuku-codegen/binding-linux-arm-gnu': 0.5.48 - '@yuku-codegen/binding-linux-arm-musl': 0.5.48 - '@yuku-codegen/binding-linux-arm64-gnu': 0.5.48 - '@yuku-codegen/binding-linux-arm64-musl': 0.5.48 - '@yuku-codegen/binding-linux-x64-gnu': 0.5.48 - '@yuku-codegen/binding-linux-x64-musl': 0.5.48 - '@yuku-codegen/binding-win32-arm64': 0.5.48 - '@yuku-codegen/binding-win32-x64': 0.5.48 - yuku-codegen@0.6.3: dependencies: '@yuku-toolchain/types': 0.5.43 @@ -7935,22 +7737,6 @@ snapshots: '@yuku-codegen/binding-win32-arm64': 0.6.3 '@yuku-codegen/binding-win32-x64': 0.6.3 - yuku-parser@0.5.48: - dependencies: - '@yuku-toolchain/types': 0.5.43 - optionalDependencies: - '@yuku-parser/binding-darwin-arm64': 0.5.48 - '@yuku-parser/binding-darwin-x64': 0.5.48 - '@yuku-parser/binding-freebsd-x64': 0.5.48 - '@yuku-parser/binding-linux-arm-gnu': 0.5.48 - '@yuku-parser/binding-linux-arm-musl': 0.5.48 - '@yuku-parser/binding-linux-arm64-gnu': 0.5.48 - '@yuku-parser/binding-linux-arm64-musl': 0.5.48 - '@yuku-parser/binding-linux-x64-gnu': 0.5.48 - '@yuku-parser/binding-linux-x64-musl': 0.5.48 - '@yuku-parser/binding-win32-arm64': 0.5.48 - '@yuku-parser/binding-win32-x64': 0.5.48 - yuku-parser@0.6.3: dependencies: '@yuku-toolchain/types': 0.5.43 diff --git a/scripts/pkg-pr-new-workflow.test.js b/scripts/pkg-pr-new-workflow.test.js new file mode 100644 index 0000000000..f4bf71ea0a --- /dev/null +++ b/scripts/pkg-pr-new-workflow.test.js @@ -0,0 +1,29 @@ +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}@\${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"); + }); + + 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"]'); + }); +});